diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce9517..881d26f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: - name: Check installer scripts shell: bash run: bash -n scripts/install.sh + - name: Validate bilingual Wiki sources + run: python3 scripts/export-wiki.py --check - name: Parse PowerShell installer shell: pwsh run: | @@ -119,6 +121,11 @@ jobs: - run: cargo fmt --all -- --check - run: cargo clippy --locked --all-targets --all-features -- -D warnings - run: cargo test --locked --all-targets --all-features + - name: Verify upstream SQLite close-ownership regression + run: >- + cargo test --locked --manifest-path vendor/libsql/Cargo.toml + --no-default-features --features core --lib + drop_closes_the_handle_exactly_once --target-dir target/libsql-regression build: name: Build (${{ matrix.platform }}) @@ -201,7 +208,7 @@ jobs: set -euo pipefail [[ "$TELEGRAM_API_ID" =~ ^[0-9]+$ ]] [[ "$TELEGRAM_API_HASH" =~ ^[0-9a-fA-F]{32}$ ]] - - name: Build and package every pushed commit + - name: Install from Git and package every pushed commit shell: bash env: VERGEN_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} @@ -210,6 +217,8 @@ jobs: run: | set -euo pipefail mkdir -p dist + source_url="$(python3 -c 'from pathlib import Path; print(Path.cwd().as_uri())')" + install_root="$(pwd)/dist/cargo-install" while IFS=$'\t' read -r sha version; do sha="${sha%$'\r'}" version="${version%$'\r'}" @@ -218,8 +227,13 @@ jobs: rustup target add "${{ matrix.target }}" export TERMGRAM_BUILD_VERSION="$version" cargo test --locked --all-targets --all-features - cargo build --locked --release --target "${{ matrix.target }}" - binary="target/${{ matrix.target }}/release/tg" + # Exercise Cargo's Git checkout, path patches, install tracking and + # release profile. Keep immutable Git-source artifacts separate from + # workspace builds, including build-script executables and metadata. + cargo install --locked --git "$source_url" --rev "$sha" \ + --bin tg --target "${{ matrix.target }}" --target-dir target/cargo-install \ + --root "$install_root" --force termgram + binary="$install_root/bin/tg" if [[ "$RUNNER_OS" == Windows ]]; then binary+=".exe" fi @@ -240,12 +254,12 @@ jobs: if [[ "$RUNNER_OS" == Windows ]]; then ( - cd "target/${{ matrix.target }}/release" + cd "$install_root/bin" 7z a -tzip \ - "../../../dist/termgram-$version-${{ matrix.platform }}.zip" tg.exe + "../../termgram-$version-${{ matrix.platform }}.zip" tg.exe ) else - tar -C "target/${{ matrix.target }}/release" -czf \ + tar -C "$install_root/bin" -czf \ "dist/termgram-$version-${{ matrix.platform }}.tar.gz" tg fi done < <(jq -r '.[] | [.sha, .version] | @tsv' <<<"$RELEASE_PLAN") diff --git a/.gitignore b/.gitignore index 7c2498e..16c5895 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /target/ +/.cargo-ok /dist/ .env *.session *.session-* *.log .DS_Store +/dev-notes/ +*.cache.sqlite3 +*.cache.sqlite3-* + +*.media/ diff --git a/Cargo.lock b/Cargo.lock index 557e7d1..ea430d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1226,8 +1226,6 @@ dependencies = [ [[package]] name = "grammers-client" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f330139772e71b5e104f5a7bbf43bbda92fd8a734b4cf9c57839e04e949cf9b" dependencies = [ "chrono", "futures-util", @@ -1951,8 +1949,6 @@ dependencies = [ [[package]] name = "libsql" version = "0.9.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30fe980ac5693ed1f3db490559fb578885e913a018df64af8a1a46e1959a78df" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -3657,17 +3653,24 @@ dependencies = [ "grammers-client", "grammers-session", "image", + "libsql", + "mlua", "moxcms 0.9.1", "qrcode", "ratatui", "ratatui-image", + "regex", + "serde", + "serde_json", "sha2", + "tempfile", "tokio", "toml", "unicode-segmentation", "unicode-width", "vergen-gitcl", "yazi-adapter", + "yazi-config", "yazi-emulator", "yazi-term", "yazi-tty", diff --git a/Cargo.toml b/Cargo.toml index 71f71ac..92fa523 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ path = "src/main.rs" anstream = "=1.0.0" anstyle = "=1.0.14" anyhow = "1.0" -chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } directories = "6.0" dotenvy = "0.15" futures-util = { version = "0.3.32", default-features = false } @@ -23,15 +23,22 @@ grammers-client = { version = "=0.10.0", default-features = false, features = [" grammers-session = { version = "=0.10.0", default-features = false, features = ["sqlite-storage"] } image = { version = "=0.25.10", default-features = false } moxcms = "=0.9.1" +mlua = { version = "=0.12.1", features = ["lua55", "vendored", "serde", "error-send"] } +libsql = { version = "=0.9.30", default-features = false, features = ["core"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "=1.13.1" qrcode = { version = "=0.14.1", default-features = false } ratatui-image = { version = "=11.1.0", default-features = false } ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm"] } sha2 = "=0.11.0" +tempfile = "=3.27.0" tokio = { version = "1.52", default-features = false, features = ["macros", "rt", "signal", "sync", "time"] } toml = "=1.1.6" unicode-segmentation = "1.13.3" unicode-width = "=0.2.2" +yazi-config = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-adapter = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-emulator = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-term = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } @@ -59,7 +66,9 @@ yazi-term = { path = "vendor/yazi-term" } [workspace] members = ["."] -exclude = ["vendor/yazi-term", "vendor/grammers-session"] +exclude = ["vendor/yazi-term", "vendor/grammers-session", "vendor/grammers-client", "vendor/libsql"] [patch.crates-io] grammers-session = { path = "vendor/grammers-session" } +grammers-client = { path = "vendor/grammers-client" } +libsql = { path = "vendor/libsql" } diff --git a/README.md b/README.md index ac924ac..84bc013 100644 --- a/README.md +++ b/README.md @@ -1,349 +1,64 @@ # Termgram -Termgram is a focused, keyboard-first Telegram client for the terminal. It uses -Telegram's full MTProto user API—not the Bot API—so it can open your existing -direct messages and groups. +A keyboard-first Telegram client for the terminal, built with Rust, Grammers and +Ratatui. Read direct messages and groups, navigate with Vim-style keys, and keep +your configuration in Lua. -The first release deliberately does less: +Termgram opens cached conversations immediately and synchronizes in the +background. It supports Telegram folders, local regex search, per-chat colors, +inline media previews, and file-manager reveal. This is an unofficial client; +broadcast channels and secret chats are outside its current scope. -- phone/login-code, optional 2FA password, and QR-code authentication; -- up to eight locally persisted Telegram accounts with fast in-app switching; -- direct-message and group conversation list with unread counts; -- recent history, live incoming messages, and read acknowledgements; -- plain-text messages with a Unicode-aware, multiline, per-chat draft; -- native replies with a visible target, optimistic delivery, and exact retry; -- drag-and-drop upload of photos and arbitrary files; -- lazy download and opening of incoming photos, files, video, audio, and stickers; -- in-app navigation for supported Telegram chat and message links; -- terminal previews for photos, image documents, and stickers; -- Unicode fallbacks for stickers and custom emoji; -- optional terminal mouse input for chats, media, scrolling, settings, and accounts; -- wide split-pane and narrow single-pane layouts; -- chat filtering, contextual help, connection/error states, and safe terminal cleanup. +**[English guide](docs/wiki/en/Home.md) · [简体中文指南](docs/wiki/zh-CN/Home.md)** -Broadcast channels are intentionally hidden. Telegram requires third-party -clients that display channels to implement additional sponsored-message -behavior, which is outside this essential messaging scope. +Documentation follows the source revision you are reading. Features on an open +PR branch may not yet be in the latest release. -## Install +## Get started -Prebuilt releases target x86_64 and ARM64 Linux, Apple silicon and Intel macOS, -and x64 and ARM64 Windows. The installers select the native architecture and -reject unsupported combinations before downloading a binary. Linux and macOS -also require Bash, `curl`, `tar`, and either `sha256sum` or `shasum`; Windows -requires PowerShell 5.1 or newer. - -On Linux or macOS: +Linux / macOS: ```sh curl --proto '=https' --tlsv1.2 -sSfL \ https://github.com/iebb/termgram/releases/latest/download/install.sh | bash ``` -This installs `tg` in `~/.local/bin`. To install the newest prerelease, or use -another directory, pass noninteractive environment overrides to `bash`: - -```sh -curl --proto '=https' --tlsv1.2 -sSfL \ - https://github.com/iebb/termgram/releases/latest/download/install.sh \ - | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash -``` - -The shortest form pipes the download directly into the shell. To inspect the -installer first, download it and run the saved copy only after reviewing it: - -```sh -curl --proto '=https' --tlsv1.2 -fLo termgram-install.sh \ - https://github.com/iebb/termgram/releases/latest/download/install.sh -less termgram-install.sh -bash termgram-install.sh -``` - -On Windows PowerShell: +Windows PowerShell: ```powershell $installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' & ([scriptblock]::Create([string]$installer)) ``` -The default destination is `%LOCALAPPDATA%\Programs\Termgram\bin`. Parameters -provide the same noninteractive overrides: - -```powershell -& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" -``` - -To inspect before executing: - -```powershell -Invoke-WebRequest 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' -OutFile termgram-install.ps1 -Get-Content termgram-install.ps1 -$installer = Get-Content termgram-install.ps1 -Raw -& ([scriptblock]::Create($installer)) -``` - -Both installers default to the highest stable `0.1.Z` release. The prerelease -channel chooses the highest `0.1.Z` release of either type, so it never -downgrades past a newer stable release. Downloads come from the public -`iebb/termgram` repository and are checked against that release's -`SHA256SUMS`; the archive must contain only `tg` or `tg.exe`. Replacement is -atomic and fails without damaging an existing binary. The scripts do not ask -for elevation or edit `PATH`; if the destination is not already present, they -print the directory to add. - -After installation, launch Termgram with `tg`. Update within the terminal with -`tg update`, or opt into the newest prerelease with `tg update --prerelease`. -Termgram checks the selected channel at most once per day in the background and -shows a quiet footer hint when an update is available; applying it is always an -explicit command. The updater trusts GitHub's HTTPS release delivery and uses -`SHA256SUMS` to verify download integrity before atomic replacement. -On Windows, replacement completes after the updating process exits. If another -`tg` process keeps the executable locked, the next launch shows a fixed warning -and `tg update` performs a fresh verified download. - -Press `s` while navigating to open the small settings overlay. It stores four -non-sensitive preferences: automatic update checks, stable/prerelease channel, -temporary-download reveal behavior, and the optional right-aligned message-ID -column. The same private settings file remembers the selected account slot; -Telegram login credentials remain exclusively in separate session databases. - -## Source build - -Official GitHub release archives include the project's Telegram application -credentials from encrypted Actions secrets, so those binaries can go directly -to the login screen. The credentials identify the client application, not your -Telegram account; like any credentials compiled into a client binary, they can -be recovered by someone inspecting that binary. Your phone, login code, 2FA -password, and session are never placed in CI and remain local. - -For a source build: - -1. Create an application at [my.telegram.org/apps](https://my.telegram.org/apps), - or use credentials for an application you already control. -2. Copy `.env.example` to `.env` and enter its API ID and API hash. -3. Run: - - ```sh - cargo run --release - ``` - -Termgram uses Rust 1.98, installed automatically by `rustup` from the pinned -`rust-toolchain.toml`. The session database defaults to the operating system's -application-data directory. It is effectively an account credential; keep it -private and never commit or share it. Set `TERMGRAM_SESSION` to override its -location for Account 1; additional account sessions use a private `accounts` -subdirectory beside it. Upgrades continue to recognize `TUIGRAM_SESSION` and an -existing TUIGram session database, so renaming the application does not sign -you out. - -After the first build, launch the optimized client directly with: +From source on any supported platform, with Rust 1.98.0 and native build tools: ```sh -./target/release/tg -``` - -On Windows, run `target\release\tg.exe`. GitHub Actions tests and packages six -native targets across Linux, macOS, and Windows. Every successful -default-branch commit is published as a prerelease with version `0.1.Z`, where -`Z` is its first-parent commit height. A commit whose subject starts with -`release:` is published as a normal release instead. Archives include the -version and platform/architecture, for example -`termgram-0.1.42-linux-aarch64.tar.gz` or -`termgram-0.1.42-macos-x86_64.tar.gz`. The legacy `linux`, `macos`, and `windows` -asset names remain the x86_64 Linux, Apple-silicon macOS, and x64 Windows builds -so existing auto-updaters remain compatible. Run `tg --version` to inspect a -binary's version and build identity: - -```text -version 0.1.0 -commit abcdef12* -branch feat/yazi-terminal-backend -os macOS -arch arm64 -build 26 +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram ``` -Labels are bold cyan in a color-capable terminal; values use its default text -color. Columns use spaces so they align independently of terminal tab stops. -Piped output is plain text by default; `NO_COLOR=1` and `TERM=dumb` also disable -styling. A `*` after the commit hash means the build included tracked modifications -or untracked files (Git-ignored files do not count). The build number is the first-parent commit count, matching release -versioning. Builds from a detached checkout report `HEAD` unless -`VERGEN_GIT_BRANCH` supplies the branch name, as it does in CI. Source archives -without Git metadata report `unknown` for unavailable fields. - -The public repository stores only the `TELEGRAM_API_ID` and -`TELEGRAM_API_HASH` secret names. GitHub encrypts their values and injects them -only into trusted default-branch builds, never pull-request builds. - -## Lightweight by design - -Termgram uses one async runtime thread plus Yazi's platform input reader. It has -no idle animation wake-up while the interface is static and bounds message -histories, outgoing commands, and network caches. It still keeps a private -SQLite session so login credentials and Telegram update state survive restarts. -Multiple `tg` processes may use the same account session concurrently; SQLite -serializes short writes and waits up to five seconds for competing writers. - -## Terminal integration - -Terminal input, protocol parsing, capability detection, and platform restoration -come from Yazi crates pinned to one upstream revision. Ratatui draws through the -same Yazi TTY writer. There is one terminal input reader; Crossterm is used only -as Ratatui's output backend. - -The terminal layer consumes capability replies separately from user input. It -detects graphics protocols, terminal identity, colors, and cell pixel size for -media previews. Detection runs alongside input and times out when a -terminal does not answer. Yazi chooses the graphics protocol; `ratatui-image` -provides multiple inline images and row-by-row clipping during timeline scrolling. -Ghostty/Kitty use Kitty graphics, iTerm2/WezTerm use inline images, and supported -terminals use Sixel. Other terminals use Unicode half-blocks without external -helpers. Decoding retains Yazi's ICC color conversion and applies EXIF orientation. -Decoding and encoding run off the UI thread, and only visible images are retained -in the encoded cache. Help, settings, account changes, resizing, and shutdown -clear or redraw the affected image areas. - -Yazi's enhanced keyboard handling supports shifted keys and associated Unicode -text from keyboard layouts. `Ctrl+J` remains a newline fallback on terminals -that cannot distinguish `Shift+Enter`. Paste, focus, mouse, resize, and keyboard -events feed the existing application state machine. Normal exit, initialization -failure, and the panic hook restore terminal modes. - -For tmux, `TERMGRAM_TMUX_PASSTHROUGH=1 tg` opts into Yazi's second-stage terminal -probe. This runs the upstream tmux setup, which sets the pane's -`allow-passthrough=all` and the server's `input-buffer-size=104857600`; those tmux -options persist after exit. Without the opt-in, Termgram uses the capabilities -reported by tmux itself and does not change these options. Set this in the shell -environment before launch, not in the Telegram credentials `.env` file. - -See [vendor/README.md](vendor/README.md) for the pinned revision, the small -`yazi-term` access patch, and the upstream files used by the integration. - -## Keyboard - -| Context | Keys | -| --- | --- | -| Anywhere | `Ctrl+C` quit, `Ctrl+L` redraw, `F2` next account, `F3` add account | -| Sign in | `Enter` submit, `Tab` opens QR login or switches its compact/full-cell display, `Esc` return to phone | -| Chats | `↑`/`↓` or `j`/`k` move, `Enter` open, `/` filter | -| Accounts | `a` open picker, `↑`/`↓` select, `Enter` switch/add, `1`–`8` direct | -| Conversation | `PgUp`/`PgDn` scroll, `Home` oldest loaded, `End`/`G` latest | -| Message actions | click or use `o`/`O` to select each action; `Enter` activates media, links, and supported bot buttons; `l` follows the selected/first link | -| Media preview | photos and stickers appear inline automatically; click/`Enter` retries a failed preview; `Ctrl+L` redraws | -| Message cursor | `[`/`]` select older/newer loaded messages, starting at the latest | -| Replies | `R` replies to the selected/latest message; right-click replies under the pointer; select an existing reply with `o`/`O`, then `r` jumps to its target | -| Wide layout | `Tab` switch pane | -| Narrow conversation | `Esc` return to chats | -| Composer | `i` or `Enter` start, `Enter` send, `Ctrl+J`/`Shift+Enter` newline; `Esc` cancels reply context first, then preserves the draft and leaves | -| Editing | arrows, `Home`/`End`, `Ctrl+A`/`Ctrl+E`, `Ctrl+W`, `Ctrl+U` | - -`?` opens contextual help while navigating. In a conversation, `/` starts a -message so Telegram bot commands remain usable; chat filtering is only active -from the chat list. Press `s` to configure the message-ID column and the other -essential preferences. - -When the terminal supports mouse reporting, clicking a chat opens it, clicking -media, a link, or an inline bot button activates it, right-clicking a message -starts a reply, and the wheel scrolls the pane under the pointer. Settings and -account rows are clickable too. Every mouse action has a keyboard equivalent, -so terminals without mouse reporting remain fully usable. - -Account switching keeps Termgram lightweight: only one account is connected at -a time. Each account has an isolated private SQLite session. Press `a` from the -chat list for the picker, or use `F2`/`F3` even from login and error screens so -an unfinished new login never traps you away from an existing account. +Run `tg`, sign in with your phone or press Tab for QR login. Open a chat with +Enter, press `i` to compose, and `?` for help. Repeat the Cargo command to update +a source install; use `tg update` for release binaries. -During sign-in, Termgram shows a fixed progress message while Telegram checks a -phone number, login code, or 2FA password. Password entry stays masked. QR login -rotates its short-lived code automatically; scan it from Telegram on a device -where the account is already signed in. Its default display follows established -terminal QR tools: exact RGB black-on-white `█`/`▀`/`▄` cells, two QR rows per -terminal row, and a two-module quiet zone. It fits an 80 × 24 terminal; press -`Tab` for a larger compatibility display made only from colored spaces when -block glyphs render poorly. QR tokens, codes, passwords, and -phone numbers are never written to settings or logs. - -Drag one or more files from the desktop into an open conversation and drop them -on the terminal. Text already in the composer becomes the first file's caption, -including when replying. JPG, JPEG, PNG, and WebP files are sent as compressed -Telegram photos; other inputs are preserved as documents. Photos, image documents, -and stickers appear directly in the chat timeline with no preview popup. Visible -media downloads automatically, with at most two preview downloads in progress; -scrolling away does not download the rest of the conversation. Files are saved in -a private per-process temporary directory with sanitized remote filenames. -Animated TGS/WebM stickers use Telegram's raster thumbnail and are labeled as -static previews; animation playback is not implemented. A missing thumbnail or -decoding failure leaves a retryable message in the timeline. Edits, deletes, and -account switches invalidate stale results. Other attachments download only when -activated and retain their second-activation reveal behavior in the operating -system's file manager; Termgram never executes downloaded files. -Downloads remain available for the current session and are left to the operating -system's normal temporary-file cleanup. - -Termgram renders Telegram URL entities—including URLs hidden behind display -text—as explicit selectable rows. Public `t.me`/`telegram.me` and `tg://resolve` -targets open in-app; ordinary HTTP(S) targets open through the operating system -without a shell. Private `t.me/c` and `tg://privatepost` links work for groups -already loaded in the conversation list. Inline URL, web-view, callback, and -game buttons are supported; password-gated, payment, contact/location, and -peer-selection buttons are shown as requiring a graphical Telegram client. -Invite links and broadcast channel links remain outside the client's scope. - -Run `cargo test` for the reducer, input, sanitization, wrapping, and rendering -behavior. - -## Design - -Termgram learns from [paul-nameless/tg](https://github.com/paul-nameless/tg) -without copying its breadth or its index-based shared mutable state. A typed, -single-owner reducer keeps selection, drafts, and scroll state stable while a -separate async Telegram worker handles network calls. The interface borrows the -calm interaction principles of modern Claude Code: a fixed composer, minimal -chrome, contextual help, explicit async states, overlays that own input, and a -real structural change on narrow terminals. - -The client uses [grammers](https://codeberg.org/Lonami/grammers), a native Rust -MTProto implementation. Grammers states that its protocol/cryptography code has -not received a formal security audit. This project is unofficial and is not -affiliated with Telegram. - -## Intentionally out of scope - -Broadcast channels, reactions, sticker/GIF selection, calls, stories, contacts, -chat creation, edits/deletes/forwarding, message search, group -administration, notifications, polls, and secret chats. +Releases support Linux x86_64/ARM64, macOS Intel/Apple silicon, and Windows +x64/ARM64. See [installation and source builds](docs/wiki/en/Getting-Started.md) +for prerequisites, installer options, and Telegram API credentials. ## Development -Follow [AGENTS.md](AGENTS.md): prefer upstream implementations, design and -implement before testing, and keep only minimal durable regression tests. -Remove temporary test scaffolding once validation is complete. +Rust 1.98 is pinned in `rust-toolchain.toml`. Create Telegram application +credentials at [my.telegram.org/apps](https://my.telegram.org/apps), copy +`.env.example` to `.env`, and fill in your API ID and hash. ```sh +cargo run --release cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets -cargo build --release -``` - -## Publishing - -Pushing an ordinary commit to the default branch creates a GitHub prerelease -after every check and all three native builds pass. Release publication reuses -those exact tested artifacts, the two installation scripts, and `SHA256SUMS`; -a failed CI run never publishes anything. - -To promote the current tree as a stable release, create a release commit and -push it: - -```sh -git commit --allow-empty -m "release: stable" -git push +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features ``` -Versions stay on the `0.1` line for now. `Z` advances once per first-parent -default-branch commit, so merge internals do not unexpectedly consume several -versions. Failed or interrupted publications are retried by the next push. -Existing tags are never moved; a height collision fails closed. Automatic -publishing starts at `0.1.2`, the commit that introduced this release pipeline. +See [development and architecture](docs/wiki/en/Development.md), +[engineering principles](AGENTS.md), and [upstream provenance](vendor/README.md). +The [Wiki source](docs/wiki/README.md) is reviewed alongside code changes and +provides English and Simplified Chinese pages. Local working plans live in the +Git-ignored `dev-notes/` directory. diff --git a/build.rs b/build.rs index c7d9f79..dd9fa9f 100644 --- a/build.rs +++ b/build.rs @@ -21,8 +21,13 @@ fn main() -> Result<(), Box> { .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) - .and_then(|number| number.trim().parse::().ok()) - .map_or_else(|| "unknown".to_owned(), |number| number.to_string()); + .and_then(|number| number.trim().parse::().ok()); + let source_version = number.map_or_else( + || env!("CARGO_PKG_VERSION").to_owned(), + |height| format!("0.1.{height}"), + ); + let number = number.map_or_else(|| "unknown".to_owned(), |number| number.to_string()); println!("cargo:rustc-env=TERMGRAM_BUILD_NUMBER={number}"); + println!("cargo:rustc-env=TERMGRAM_SOURCE_VERSION={source_version}"); Ok(()) } diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..a4d9c4f --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,13 @@ +# Termgram Wiki + +[English](en/Home.md) · [简体中文](zh-CN/Home.md) + +Termgram is a keyboard-first Telegram client for direct messages and groups. +Choose a language for setup, keybindings, Lua configuration, daily workflows, +and maintenance documentation. + +Termgram 是面向私聊与群组的终端 Telegram 客户端。请选择语言,查看安装、快捷键、 +Lua 配置、日常操作与维护说明。 + +Documentation follows its source revision. Open PR features may not be available +in the latest release. 文档对应其来源版本,尚未合并的 PR 功能不一定已包含在最新发行版中。 diff --git a/docs/wiki/README.md b/docs/wiki/README.md new file mode 100644 index 0000000..862fe7e --- /dev/null +++ b/docs/wiki/README.md @@ -0,0 +1,46 @@ +# Maintaining the Wiki + +[English guide](en/Home.md) · [简体中文指南](zh-CN/Home.md) + +This directory is the reviewed source for the project Wiki. User documentation +is tracked with its features; local development plans stay in Git-ignored +`dev-notes/`. Keep the same page filenames in `en/` and `zh-CN/`, use relative +inline Markdown links, and update both languages in the feature's PR. + +The exporter uses Python 3.9+ standard-library modules. It validates language +parity and local link targets, flattens `en/Configuration.md` to +`en-Configuration.md`, rewrites links, and generates a language sidebar. It does +not render Markdown, push Git commits, or remove unrelated Wiki pages. + +```sh +python3 scripts/export-wiki.py --check +python3 scripts/export-wiki.py --output dist/wiki +``` + +Repository links in exports point to the current commit. Export from a clean, +published source revision; `--source-ref REF` can select another published ref. +Documentation on an unmerged branch describes that branch, not necessarily the +latest installer release. Source pages remain browsable on GitHub without export. + +To publish the approved source, initialize the Wiki with one page on GitHub if it +does not already exist, then clone its separate repository: + +```sh +git clone https://github.com/iebb/termgram.wiki.git ../termgram.wiki +python3 scripts/export-wiki.py --output ../termgram.wiki +git -C ../termgram.wiki diff +git -C ../termgram.wiki add -- '*.md' +git -C ../termgram.wiki commit -m "docs: synchronize bilingual user guides" +git -C ../termgram.wiki push +``` + +Review renamed/deleted pages manually as part of the diff. GitHub only publishes +the Wiki's default branch. See the official +[Wiki editing instructions](https://docs.github.com/en/communities/documenting-your-project-with-wikis/adding-or-editing-wiki-pages). +The normal feature PR does not automatically merge or publish Wiki changes. + +这里是 Wiki 的可审阅源文件。英文与简体中文保持相同页名,使用相对 Markdown 链接, +随功能 PR 同步修改。`--check` 校验两种语言的页面和内部链接;`--output` 导出为 +GitHub Wiki 的扁平页名并生成语言侧栏。脚本不联网、不推送、不删除无关页面。 +从干净且已发布的源码提交导出,审阅 Wiki diff 后再提交推送;首次发布需要在 GitHub +先创建一个页面。未合并分支的文档可能包含最新发行版尚未提供的功能。 diff --git a/docs/wiki/en/Appearance.md b/docs/wiki/en/Appearance.md new file mode 100644 index 0000000..47afe77 --- /dev/null +++ b/docs/wiki/en/Appearance.md @@ -0,0 +1,60 @@ +# Appearance + +[简体中文](../zh-CN/Appearance.md) · [Guide](Home.md) + +Press `c` on a chat, or in its conversation, to choose its color. Press `C` in the +chat list to color the current folder. Up/Down selects a named terminal color; +Enter applies it and Esc cancels. The list marker and selection styling remain +visible independently of the chosen color. + +Chat rows and conversation titles use the terminal's default foreground unless +configured otherwise. Folder titles use a stable color chosen from six ANSI +palette entries. Your terminal theme controls the actual RGB values; the app +does not assume a dark background for these colors. + +Precedence is built-in default → Lua → in-app override. Choose **Follow +configuration** to remove an override. **Terminal default** is an explicit color +choice and can override a colored Lua default. + +```lua +return { + colors = { + chats = { [-1001234567890] = "cyan" }, + folders = { [2] = "yellow" }, + }, +} +``` + +Values are `default`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, +`gray`, `dark_gray`, `light_red`, `light_green`, `light_yellow`, `light_blue`, +`light_magenta`, `light_cyan`, and `white`. `g i` shows chat and current folder IDs. + +In-app overrides live in `appearance.json` beside `settings.conf`, keyed by the +actual Telegram account ID. Switching accounts or clearing the message cache +does not mix or delete them. Writes reuse the atomic settings writer. A malformed +preferences file is reported and is not silently replaced. Your Lua configuration +and the server's folder colors are never rewritten by this picker. + +## Nerd Font icons + +Nerd Font support is opt-in. Install a [Nerd Font](https://www.nerdfonts.com/font-downloads) +and select its **Nerd Font Mono** variant in the terminal profile, then add this +to `config.lua` and restart Termgram: + +```lua +return { + nerd_font = true, +} +``` + +Use a v3+ font. Icons identify chat types, folders, Archive, pins and attachments; +text labels, key hints and terminal colors remain visible. The Mono variant keeps +icons within terminal cells. With SSH or tmux, configure the font on the terminal +that displays the session. Termgram uses the terminal's selected font and does +not install fonts or change terminal preferences. + +The default `nerd_font = false` retains the ordinary text presentation and `^` +pin marker. Turn the option off if glyphs appear as boxes or overlap adjacent text. +Configuration errors are reported through the existing Lua configuration loader. + +Font reference: [Nerd Fonts font variants](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting). diff --git a/docs/wiki/en/Attachments.md b/docs/wiki/en/Attachments.md new file mode 100644 index 0000000..0d28326 --- /dev/null +++ b/docs/wiki/en/Attachments.md @@ -0,0 +1,47 @@ +# Attachments and file previews + +[简体中文](../zh-CN/Attachments.md) · [Guide](Home.md) + +Select an attachment with `j/k` (or click it) and press uppercase `O` (`Shift-O`) +to reveal it in your system file manager. This is a single press, not a timed +hold gesture. Reported key-repeat events are ignored. A second activation while +the same file is downloading does not start another transfer. + +An existing full-size image preview or downloaded file is reused. Otherwise +Termgram downloads the original attachment and reveals it when ready. Animated +sticker thumbnails are previews only; `O` downloads the original. Missing cached +files are downloaded again. Failures appear in the status area and can be retried. + +macOS selects the file in Finder; Windows selects it in Explorer. Linux opens the +containing directory through `xdg-open` (selection support varies by file manager). +The file itself is not executed. `O` is an explicit reveal action even when the +settings menu's Enter download behavior is **Keep in cache**. + +A single click selects media and shows its available keys. Press `o` to expand an +image or sticker in the terminal; Esc closes the preview, `i` replies, and `O` +reveals the original file. Selection itself does not activate links, bot buttons +or file-manager operations. Use `g n` / `g p` to cycle actionable items and Enter +to activate one. Lua actions are `preview`, `next_action`, `previous_action` and +`reveal`; the expanded view uses the `preview` context. + +Original media IDs and request generations prevent an older transfer from +replacing an edited or deleted attachment. Download mappings are stored with the +message cache and survive restart. Completed files live in +`.media//`. Termgram keeps at most 512 +completed files and targets a 1 GiB budget per directory, removing older files. +The file just downloaded is retained even if it alone exceeds the byte budget; +a later cleanup can remove it. Copy files you want to keep outside this cache. + +Partial downloads use the maintained `tempfile` crate and stay separate from +completed files. Cancellation removes partial files; interrupted-process remnants +are cleaned before the next transfer. The account cache has one process owner, +and outstanding media tasks retain its lock through cleanup. This also prevents +two clients from independently advancing one message-cache checkpoint. + +Photos, image documents and stickers preview in the timeline. Only visible +previews are requested, with at most two preview downloads pending in the app; +all network transfers share a bounded worker. Animated TGS/WebM stickers show +Telegram's raster thumbnail and are labeled as static previews. Failed previews +remain retryable through `o` or Enter after selection. Ctrl-L redraws; terminal protocol details +are in [Terminal](Terminal.md). Drop local file paths to upload as described in +[Daily workflows](UX.md). diff --git a/docs/wiki/en/Configuration.md b/docs/wiki/en/Configuration.md new file mode 100644 index 0000000..0abba71 --- /dev/null +++ b/docs/wiki/en/Configuration.md @@ -0,0 +1,128 @@ +# Lua configuration + +[简体中文](../zh-CN/Configuration.md) · [Guide](Home.md) + +## Load configuration + +Place `config.lua` beside Termgram's `settings.conf`, or set `TERMGRAM_CONFIG` to +an explicit file path. Restart to load changes. Start with [the example](../../../examples/config.lua). +The file returns a Lua table; tables, strings, mathematics and UTF-8 helpers are +available. Filesystem, process and plugin APIs are deliberately outside this +configuration interface. Invalid files show an error and retain default bindings. + +```lua +return { + chats = { work = -1001234567890 }, + ghost_text = "{send} to send · {newline} for a new line", + nerd_font = false, + keymap = { + { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + { context = "conversation", on = { "g", "w" }, run = "jump work" }, + { context = "compose", on = { "" }, run = "newline" }, + { context = "compose", on = { "" }, run = "send" }, + }, +} +``` + +Contexts are `global`, `chats`, `conversation`, `compose`, `input` (login and chat +filter), `overlay`, `preview`, `pins`, and `search`. A context takes precedence over global bindings. Rebinding +an existing key replaces that binding. Use `run = "noop"` to remove a binding. +A chord is a list of individual keys, such as `{ "g", "w" }`; ambiguous prefixes +are rejected. Chords expire after one second; Escape cancels a pending chord. +Key spelling follows Yazi: ``, ``, ``, ``, ``. + +Chat aliases use stable numeric Telegram IDs, including the `-100…` IDs of +supergroups. The destination must be present in the cached conversation list. +The optional `desc` field supplies the binding's label in help. Help always +reflects the effective bindings; use the arrow keys to scroll it. + +Navigation defaults: `j/k` move between chats or actual messages; `20k` moves +20 messages up, fetching older pages if necessary. `G` or End returns to the +latest messages; `gg` or Home goes to the oldest message in the loaded window. +Arrow keys scroll rendered rows. PageUp/PageDown scroll ten rows. `i` starts +composing, `R` replies, `r` opens a reply target, `/` filters the chat list, +Tab switches panes, `s` opens settings, `a` opens accounts, and `?` opens help. +Counts and navigation chords never consume ordinary composer text. + +Use `count = 20` with `message_up` for a single shortcut that moves up 20 +messages. The optional count defaults to 1 and accepts 1–9999 for `up`, `down`, +`message_up`, `message_down`, `page_up`, and `page_down`. A typed prefix multiplies +the configured count, capped at 9999. Other actions do not accept a count. + +Composer placeholders use the effective `{send}`, `{newline}` and `{cancel}` +bindings. Set `ghost_text = ""` to hide them. Managed in-app preferences remain +in their own file; Termgram does not rewrite your Lua configuration. + +Press `g i` to show the current chat ID for an alias. + +Set `nerd_font = true` when your terminal uses a Nerd Font Mono (v3+). It enables +chat, folder, archive, pin and attachment icons; `false` is the default. See +[Appearance](Appearance.md) for font selection and fallback behavior. + +## File locations + +`config.lua`, `settings.conf`, `appearance.json` and `navigation.json` use the configuration directory. +`navigation.json` remembers the last opened chat by Telegram account ID; it stores no draft text. +The session, message database and media use the data directory. Defaults: + +| OS | Configuration directory | Data directory | +| --- | --- | --- | +| Linux | `$XDG_CONFIG_HOME/termgram` or `~/.config/termgram` | `$XDG_DATA_HOME/termgram` or `~/.local/share/termgram` | +| macOS | `~/Library/Application Support/dev.termgram.Termgram` | Same | +| Windows | `%APPDATA%/termgram/Termgram/config` | `%LOCALAPPDATA%/termgram/Termgram/data` | + +Account 1 uses `termgram.session`; other slots use +`accounts/.account-N` beside it. Each gets its own appended +`.cache.sqlite3` database and `.media` directory. Session files contain account +credentials; cached messages and media are local plaintext. Cache cleanup is +described in [Synchronization](Synchronization.md). + +| Environment variable | Purpose | +| --- | --- | +| `TELEGRAM_API_ID`, `TELEGRAM_API_HASH` | Source-build application credentials; environment or `.env` takes precedence over embedded values | +| `TERMGRAM_SESSION` | Override Account 1 session path; other slots derive from it | +| `TERMGRAM_CONFIG` | Override the Lua file; set in the shell before starting | +| `TERMGRAM_TMUX_PASSTHROUGH=1` | Opt into terminal passthrough setup; see [Terminal](Terminal.md) | + +Lua is loaded before the credentials `.env`, so `TERMGRAM_CONFIG` must already +be in the process environment. A previous `TUIGRAM_SESSION` override and an +existing TUIGram default session remain recognized for login continuity. +Changing the session path does not move settings or colors. + +Lua source is limited to 64 KiB, an 8 MiB VM budget, and approximately one million +instructions. Unknown configuration fields, actions, aliases and ambiguous +same-context prefixes are reported; the entire invalid configuration falls back +to defaults. There is no arbitrary plugin API or live reload. + +## Action reference + +Bind actions in an appropriate context. A custom global binding is a fallback; +a more specific binding or chord prefix takes precedence. Help labels can be +customized with `desc`. `noop` removes that context's key, so an existing global +binding can become visible again. + +| Actions | Usual context and behavior | +| --- | --- | +| `quit`, `redraw`, `next_account`, `add_account` | Global lifecycle/account controls | +| `help`, `settings`, `accounts` | Navigation; open or toggle the overlay | +| `open`, `cancel`, `focus` | Contextual activation, dismissal, pane/QR switching | +| `up`, `down`, `page_up`, `page_down` | List selection, rendered-row scrolling, or search selection; accept `count` | +| `message_up`, `message_down` | Conversation message cursor; accept `count` | +| `oldest`, `latest` | First/last chat, or loaded-history start/latest conversation | +| `compose`, `send`, `newline` | Enter a draft or reply to the explicit conversation selection / send / newline | +| `preview` | Expand a selected image or sticker; `preview` context controls the expanded view | +| `home`, `end`, `left`, `right`, `backspace`, `delete`, `clear`, `delete_word` | Editors | +| `filter`, `refresh`, `chat_info` | Navigation; title filter, lists refresh, IDs | +| `folder_previous`, `folder_next` | Chat list folders | +| `pin`, `pin_up`, `pin_down`, `archive` | Chat list; native folder pins and archive | +| `reply`, `reply_target`, `open_link`, `next_action`, `previous_action`, `reveal` | Conversation actions | +| `chat_color`, `folder_color` | Appearance pickers | +| `pin`, `pins` | Conversation; confirm pin/unpin, browse all pins | +| `pins_more`, `pins_previous`, `unpin_all` | Pinned-message overlay (`pins` context) | +| `search` | Open local search | +| `search_scope`, `search_query`, `search_more`, `search_previous` | Search overlay | +| `jump ALIAS` | Open the configured stable chat ID; returns to navigation | +| `noop` | Remove a binding from its declared context | + +Use [Keybindings](Keybindings.md) for defaults and [Appearance](Appearance.md) +for the `colors` table and the precedence of in-app overrides. diff --git a/docs/wiki/en/Development.md b/docs/wiki/en/Development.md new file mode 100644 index 0000000..7045265 --- /dev/null +++ b/docs/wiki/en/Development.md @@ -0,0 +1,144 @@ +# Development and architecture + +[简体中文](../zh-CN/Development.md) · [Guide](Home.md) + +Follow [AGENTS.md](../../../AGENTS.md). Start from a described problem, inspect +upstream behavior and callers, implement, then validate. Local plans and +investigation output belong in ignored `dev-notes/`; user documentation belongs +here in both languages. Use descriptive feature branches and atomic buildable +Conventional Commits. Submit separate PRs, using explicit base branches when +changes depend on one another. + +## Build and validate + +Setup and credentials are in [Get started](Getting-Started.md). Use the pinned +Rust toolchain and committed lockfile: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features +cargo build --locked --release +python3 scripts/export-wiki.py --check +``` + +Keep small regressions for observable behavior and integration boundaries. +Existing tests cover reducer state, Unicode input, terminal rendering, cache +restart/replay, concurrent session processes, message-box recovery, folder +membership, Lua routing, local regex search, attachment identity and retention. +Use synthetic fixtures, not account credentials or downloaded private messages. +No TDD requirement or new general test framework is imposed. Do not duplicate +upstream parser/database tests. Formatting and linting apply to the root +workspace, preserving vendored upstream source conventions. + +CI runs the same checks and installs each release with `cargo install --git` +from the checked-out Git revision on six native hosts. It exercises package +selection, repository-local patches, the lockfile, installation tracking and +the installed binary's version before packaging that executable. Build artifacts +are reused in `target/cargo-install`, separate from workspace builds and their +build-script metadata. See [Updates](Updates.md) +for versioning and publication. Automated fixtures do not establish real Telegram +latency parity or verify a desktop file manager's selection behavior. For those, +use an authorized test account and the target operating system; do not send +messages to other people as an implicit test. + +## Ownership and boundaries + +```mermaid +flowchart LR + T[One Yazi terminal reader] --> A[App reducer] + A --> L[Local coordinator] + L --> C[SQLite message cache] + L --> N[Grammers worker] + N --> L + L --> A + A --> R[Ratatui and preview renderer] + L --> S[Bounded local search worker] + N --> M[Bounded media transfers] +``` + +- `src/app.rs` owns selection, focus, drafts and request generations. Feature + reducers live in `src/app/`; rendering stays in `src/ui/`. +- `src/keymap.rs` loads declarative Lua and maps Yazi keys to actions. Contexts, + counts, chords and hints share one binding collection. `config.lua` is user + owned; managed settings and account-specific appearance are separate files. +- `src/telegram/local.rs` opens the account cache before networking, serves local + requests, bounds commands waiting for authentication, and persists changes. + `src/cache.rs` owns schema migration, history coverage, revisions and retention. +- `src/telegram/mod.rs` owns the client, update stream and task lifetimes. + `requests.rs` bounds ordinary RPC work; transfers have a separate limit. + Slow dialog/history/peer RPCs do not occupy the update-consumption path. +- `src/search.rs` scans local message text with the maintained regex engine. + It has one active blocking scan and one replaceable pending request, generation + checks, cancellation and cursor pagination. It never fetches remote history. +- `src/telegram/media_cache.rs` manages completed media and `tempfile` partials; + `src/app/attachments.rs` adapts selected attachments to download/reveal actions. + `src/terminal.rs` owns modes and cleanup, while `src/media.rs` coordinates + graphics with the same output stream. + +## Synchronization contracts + +Cached display must not wait for authentication, dialogs or a full history scan. +The live update receive stays pinned while command and completion events are +handled; cancelling and recreating it can discard in-progress recovery. The +currently focused group uses Grammers' update engine with server-directed +channel-difference timeouts. General push processing and gap recovery continue +when active polling stops. + +A durable cursor belongs to a complete covered update batch. Commit messages, +edits, tombstones and that cursor together; advancing a session cursor alone is +not evidence that message content is cached. Restore the application cursor on +restart. Too-long differences invalidate affected history before replacement +content. Replayed incoming messages must not double-count unread state. + +Chat previews use the dialog's actual top message ID, not the last message in +a currently loaded historical window. In-flight snapshots cannot restore deleted +messages or overwrite newer edits/read state. Exact cached page boundaries +prevent sparse reply/search context from masquerading as complete history. +Account switching cancels old work and rejects stale request generations. + +Only one process owns an account's message cache. File transfers and cleanup +retain the same lock lifetime. The underlying session format still supports +serialized SQLite writes, but two complete clients must not advance one message +cache independently. Keep the current rollback journal until upgrading the +bundled SQLite engine and reviewing the upstream WAL-reset fix. + +## Reuse decisions + +| Upstream | Reused or studied | Local responsibility | +| --- | --- | --- | +| Yazi `9203fd2604f867ab5ec18f24203b918975c4c00a` | Terminal lifecycle, parser, capabilities, TTY, public key normalization; pinned crates | Small adapters and visibility patch; no second input reader | +| Codex `78245b47af2a7aafcabe025828ceecca69db4df1` | Context precedence, chord cancellation, effective hints and composer UX | Design reference; no Codex TUI fork or copied plugin runtime | +| Telegram Desktop `4d4da471fbee771c10e173a83c003ba1728989f1` | Active-group recovery, native filter semantics and local-first storage | Behavioral reference through Telegram API/Grammers; no Desktop storage-format clone | +| Grammers 0.10.0 | MTProto transport and update ordering | Narrow batch/cursor/active-channel patches; retained upstream licenses | +| libSQL / regex / mlua / tempfile / ratatui-image | Storage, regex, bounded Lua evaluation, partial files, inline rendering | Application schemas, limits, commands and UX | + +Source references: [Yazi](https://github.com/sxyazi/yazi/tree/9203fd2604f867ab5ec18f24203b918975c4c00a), +[Codex TUI](https://github.com/openai/codex/tree/78245b47af2a7aafcabe025828ceecca69db4df1/codex-rs/tui), +[Telegram Desktop](https://github.com/telegramdesktop/tdesktop/tree/4d4da471fbee771c10e173a83c003ba1728989f1), +[Telegram updates](https://core.telegram.org/api/updates), +[dialog filters](https://core.telegram.org/api/folders). + +TDLib supplies a maintained client/database stack, but adopting it here would +replace authentication, message mapping, media and the existing Grammers +integration. The present change keeps Grammers and confines required adaptations +to documented SDK boundaries. Reconsider that choice if upstream maintenance or +update correctness can no longer be kept narrow. + +Update all Yazi Git revisions together. Prefer released dependencies or immutable +Git revisions; never depend on sibling local checkouts. For copied code retain +licenses and document the repository, revision, source path and adaptations in +[vendor/README.md](../../../vendor/README.md). Remove superseded infrastructure +instead of maintaining two paths indefinitely. + +## UX maintenance + +Hints come from effective bindings. Empty editors use ghost text, and overlays +own input and provide an escape route. Preserve drafts and reading position +across view changes. State scope explicitly: loaded history is not full history, +cached search is not a server query, and a loading badge is not an empty result. +Color supplements focus markers rather than carrying meaning alone. + +Keep English and Simplified Chinese pages together. The small +[Wiki export workflow](../README.md) validates page parity and internal links, +then maps this directory to GitHub Wiki filenames without a documentation framework. diff --git a/docs/wiki/en/Folders.md b/docs/wiki/en/Folders.md new file mode 100644 index 0000000..f89f3cc --- /dev/null +++ b/docs/wiki/en/Folders.md @@ -0,0 +1,49 @@ +# Telegram folders + +[简体中文](../zh-CN/Folders.md) · [Guide](Home.md) + +Termgram reads the folders already configured in Telegram, including shared +folders. Their order, pinned chats and inclusion/exclusion rules are synchronized +and cached per account. Use Telegram Desktop or another official client to create +or edit them; changes appear in Termgram automatically. + +In the chat list, `[` and `]` select the previous/next folder. The list border shows +the folder name and position. These keys are configurable as `folder_previous` +and `folder_next` in the `chats` context. `/` filters the selected folder. +Switching folders leaves the open conversation, reading position and draft intact. +A configured `jump alias` opens its destination from All chats or Archive, so it +also works when the destination is outside the selected folder. See [configuration](Configuration.md). + +Explicit exclusions take priority, followed by explicit inclusions and pins. +Dynamic rules support contacts, non-contacts, bots, groups, unread, muted and +archived conversations. Bots are a distinct category. Muted chats with unread +mentions remain eligible unless archived, matching Telegram Desktop. Notification +defaults are inherited when the chat has no explicit mute override. Other clients' +read notifications update unread-folder membership. `Ctrl-R` refreshes folders +and conversations if a retry is needed. + +Termgram currently exposes people and groups. Broadcast channels remain outside +its messaging scope, including when they appear in an upstream folder. + +## Archive + +Archive is a separate folder (ID `1`); All chats (ID `0`) excludes archived chats. +Select a chat and press `e` (`archive`) to archive it, or restore it from Archive. +Custom Telegram folders still follow their own inclusion and `exclude_archived` +rules. Changes from another client update membership automatically. + +## Pinned chats + +In the chat list, `p` (`pin`) toggles the selected chat's server-side pin. +`Ctrl-K` / `Ctrl-J` (`pin_up` / `pin_down`) move a pinned chat up/down. Main, +Archive, and each custom folder have independent server orders. A `^` marker +identifies pins; newer messages do not move other chats ahead of them. Selection +stays on the same chat after reordering, and cached pin order survives restart. + +Unpinning in a custom folder keeps that chat explicitly included, as in Telegram +Desktop. Folder updates preserve server titles, colors, shared-folder settings +and filter rules. Pin/Archive mutations require a connection and wait for server +confirmation; server limits and permission failures are reported without a local +success indicator. Local appearance settings do not modify Telegram folders. + +Implementation reference: [Telegram dialog filters](https://core.telegram.org/api/folders). diff --git a/docs/wiki/en/Getting-Started.md b/docs/wiki/en/Getting-Started.md new file mode 100644 index 0000000..a4b92fc --- /dev/null +++ b/docs/wiki/en/Getting-Started.md @@ -0,0 +1,165 @@ +# Get started + +[简体中文](../zh-CN/Getting-Started.md) · [Guide](Home.md) + +## Install a release + +Native releases cover Linux x86_64/ARM64, macOS Intel/Apple silicon, and Windows +x64/ARM64. Linux/macOS need Bash, `curl`, `tar`, and `sha256sum` or `shasum`. +Windows needs PowerShell 5.1 or newer. Use a terminal with at least 40 × 10 cells; +80 × 24 or larger is more comfortable. + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh | bash +``` + +The default destination is `~/.local/bin`. To inspect the installer before +running it, save the same URL to a file, read it, then run `bash install.sh`. +For a prerelease or another destination: + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh \ + | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash +``` + +Windows PowerShell: + +```powershell +$installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' +& ([scriptblock]::Create([string]$installer)) +# Optional: +& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" +``` + +The Windows default is `%LOCALAPPDATA%\Programs\Termgram\bin`. Inspect `$installer` +first if desired. Installers verify the release's `SHA256SUMS`, select the native +architecture, and print the destination to add to PATH when needed. They do not +request elevation or edit PATH. Release selection and updates are described in +[Updates](Updates.md). + +## Sign in and send a message + +1. Run `tg`. Official release binaries include the project's Telegram application + credentials. A source build needs your own credentials as described below. +2. Enter your phone in international format, then the login code and optional 2FA + password. Password entry is masked. Tab on the phone screen starts QR login; + scan it in Telegram under **Settings → Devices → Link Desktop Device**. +3. Move through chats with `j/k` or arrows, then Enter to open one. +4. Press `i`, type your message, and Enter to send. Ctrl-J adds a newline. + Esc leaves the editor while keeping the draft in this running account. +5. `G` returns to the newest messages; `?` opens help. `q` quits from navigation. + +QR codes rotate automatically. Tab switches between compact block rendering and +larger full-cell rendering. Esc returns to phone sign-in. F2 switches to the next +existing account; F3 adds an account, including from login/error screens. +All listed keys are defaults and can be [configured](Configuration.md). + +## Install from source with Cargo + +The same Cargo command supports Linux x86_64/ARM64, macOS Intel/Apple silicon, +and Windows x64/ARM64. It builds the selected Git source with the committed +lockfile and installs `tg` (`tg.exe` on Windows) using Cargo's release profile. + +### Set up the build tools once + +Install [Rust through rustup](https://rustup.rs/) and Git, plus the native C +compiler/linker. Lua and SQLite are compiled with the application; separate Lua +or SQLite packages are not required. + +| Platform | Native build tools | +| --- | --- | +| Linux | C compiler, linker, make and Git; on Debian/Ubuntu: `sudo apt-get install build-essential git` | +| macOS | Xcode Command Line Tools: `xcode-select --install` | +| Windows | Visual Studio C++ build tools and Windows SDK, with the MSVC tools for your CPU; ARM64 needs the ARM64 component. Follow the [rustup MSVC setup](https://rust-lang.github.io/rustup/installation/windows-msvc.html). | + +Install the project's pinned compiler without changing your default toolchain: + +```sh +rustup toolchain install 1.98.0 --profile minimal +``` + +### Install or update + +This single command works in Bash, Zsh and PowerShell: + +```sh +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram +``` + +It installs the repository's default branch. Repeat it to update to the latest +source on that branch. `+1.98.0` selects the compiler explicitly, including when +you run the command outside a checkout. A fetched repository's toolchain file +does not select the Cargo process you already started. + +Cargo normally installs to `~/.cargo/bin/tg` on Linux/macOS and +`%USERPROFILE%\.cargo\bin\tg.exe` on Windows. Ensure that directory is on PATH. +A custom `CARGO_HOME` or Cargo install-root setting can change the destination; +`--root DIR` installs into `DIR/bin`, so pass the parent of the desired bin folder. +If an older `tg` takes precedence, inspect `type -a tg` in Bash/Zsh or +`Get-Command tg -All` in PowerShell and remove the obsolete executable from PATH. + +To select another branch, add `--branch BRANCH`; for a specific commit, use +`--rev COMMIT` instead. Replace these placeholders with the desired Git reference. +PR features require that PR's source branch until merged. The Git URL is required +for this installation route; do not substitute `cargo install termgram`, which +would look for a crates.io package. + +`cargo uninstall termgram` removes the Cargo-managed executable, leaving your +Termgram configuration and account data. If you installed with `--root`, pass the +same root when updating or uninstalling. `--force` rebuilds the same revision, +for example after changing embedded application credentials. `--target-dir DIR` +can keep compilation artifacts for later source updates. + +See [Cargo's installation reference](https://doc.rust-lang.org/cargo/commands/cargo-install.html) +for installation tracking and options, and [Updates](Updates.md) for the difference +between a Cargo source update and `tg update`. + +### Telegram application credentials + +Git source installations need your own application API ID and hash from +[my.telegram.org/apps](https://my.telegram.org/apps). These identify the client; +you still sign in with your Telegram account after starting `tg`. Source installs +do not receive the project's GitHub Actions release credentials. + +Set these runtime variables before launching. In Bash/Zsh: + +```sh +export TELEGRAM_API_ID=123456 +export TELEGRAM_API_HASH=your_api_hash +tg +``` + +In PowerShell: + +```powershell +$env:TELEGRAM_API_ID = '123456' +$env:TELEGRAM_API_HASH = 'your_api_hash' +tg +``` + +Replace the example values. Runtime variables override embedded defaults. +Alternatively, the app reads `.env` from its working directory or a parent. +For a personal binary that works without runtime variables, set +`TERMGRAM_EMBEDDED_API_ID` and `TERMGRAM_EMBEDDED_API_HASH` in the build environment +before running Cargo. Those application credentials become part of the executable; +your account session is never embedded. + +### Install a local checkout + +For development, clone the repository and optionally copy `.env.example` to +`.env` for runtime credentials, then install the checkout: + +```sh +git clone https://github.com/iebb/termgram.git +cd termgram +cargo install --locked --path . --bin tg +``` + +This uses the checkout's pinned toolchain. `cargo run --locked --release` runs it +without installing. Do not commit `.env` or session databases. + +Use [configuration](Configuration.md) for paths and overrides, +[terminal integration](Terminal.md) for graphics/key issues, and +[synchronization](Synchronization.md) for offline/cache behavior. diff --git a/docs/wiki/en/Home.md b/docs/wiki/en/Home.md new file mode 100644 index 0000000..e8b8339 --- /dev/null +++ b/docs/wiki/en/Home.md @@ -0,0 +1,35 @@ +# Termgram guide + +[简体中文](../zh-CN/Home.md) · [Language selection](../Home.md) + +Termgram uses Telegram's MTProto user API to access your direct messages and +groups. It supports up to eight account slots, with one account connected at a +time. Cached messages are available while it reconnects. + +| I want to… | Read | +| --- | --- | +| Install, sign in, or build from source | [Get started](Getting-Started.md) | +| Learn the keys or migrate older bindings | [Keybindings](Keybindings.md) | +| Remap keys, jump to a saved chat, change ghost text | [Lua configuration](Configuration.md) | +| Read, reply, send files, and switch accounts | [Daily workflows](UX.md) | +| Navigate Telegram folders | [Folders](Folders.md) | +| Pin chats or messages, browse pins | [Pins](Pins.md) | +| Find text in cached messages | [Local regex search](Search.md) | +| Color a chat or folder | [Appearance](Appearance.md) | +| Preview or reveal a file | [Attachments](Attachments.md) | +| Understand offline behavior or clear the cache | [Cache and synchronization](Synchronization.md) | +| Troubleshoot graphics, keys, or tmux | [Terminal integration](Terminal.md) | +| Update the app or understand releases | [Updates](Updates.md) | +| Contribute and maintain the implementation | [Development](Development.md) | + +These pages describe their source revision; check the release you installed +with `tg --version`. The app's `?` help reflects your actual Lua bindings. +Documentation is available in English and Simplified Chinese; the interface +currently uses English text, with configurable composer ghost text. + +Broadcast channels, secret chats, calls, stories, reactions, sticker/GIF pickers, +server-wide message search, contact management, chat creation, group +administration, polls, notifications, and outgoing edits/deletes/forwarding are +outside the current scope. Incoming edits and deletions are synchronized. +Folders are created and edited in an official client. Supported messages, links +and bot buttons are described in [daily workflows](UX.md). diff --git a/docs/wiki/en/Keybindings.md b/docs/wiki/en/Keybindings.md new file mode 100644 index 0000000..fbf84a9 --- /dev/null +++ b/docs/wiki/en/Keybindings.md @@ -0,0 +1,77 @@ +# Keybindings + +[简体中文](../zh-CN/Keybindings.md) · [Guide](Home.md) + +These are defaults. `?` displays the current Lua bindings. Uppercase keys mean +Shift plus that letter. `g g`, `g i`, `g n`, and `g p` are successive presses; a pending +chord times out after one second and Esc cancels it. Keys apply to the focused +pane or overlay, so ordinary letters in an editor remain text. + +| Context | Keys | Action | +| --- | --- | --- | +| Global | Ctrl-C / Ctrl-L | Quit / redraw | +| Global | F2 / F3 | Next account / add account | +| Login | Enter / Esc | Submit / restart phone sign-in | +| Login | Tab or Shift-Tab | Start QR login or change QR display | +| Chats | j/k or Down/Up | Select next/previous chat | +| Chats | Enter or Right | Open selected chat | +| Chats | i | Compose in the current chat, or resume the last chat for this account | +| Chats | G / g g | Last / first chat in the filtered list | +| Chats | PageDown/PageUp | Move ten chats | +| Chats | / | Filter titles in this folder | +| Chats | Ctrl-F | Open local regex search | +| Chats | [ / ] | Previous / next Telegram folder | +| Chats | c / C | Chat / folder color picker | +| Chats | p | Pin/unpin in the current folder | +| Chats | Ctrl-K / Ctrl-J | Move a pinned chat up/down | +| Chats | e | Archive / restore selected chat | +| Conversation | j/k or ]/[ | Select next/previous message | +| Conversation | 20k | Move up 20 messages; fetch older pages if needed | +| Conversation | Up/Down | Scroll rendered rows | +| Conversation | PageUp/PageDown | Scroll ten rendered rows | +| Conversation | G or End | Return to latest messages and follow incoming messages | +| Conversation | g g or Home | Oldest message in the loaded window | +| Conversation | i | Reply to an explicitly selected message; otherwise compose | +| Conversation | Enter | Activate the selection; compose if nothing is selected | +| Conversation | R / r | Reply to selection/latest / open its reply target | +| Conversation | o | Expand selected image or sticker | +| Conversation | g n / g p | Next / previous actionable item | +| Conversation | l | Open the selected or first supported link | +| Conversation | O | Reveal attachment in the system file manager | +| Conversation | / / c | Local regex search / chat color | +| Conversation | p / P | Pin/unpin selected message / browse pins | +| Pinned messages | Enter / Esc | Open selected message / close | +| Pinned messages | Ctrl-N / Ctrl-P | Next / previous page | +| Pinned messages | p / U | Confirm unpin / unpin all | +| Pinned messages | Ctrl-R | Refresh pins | +| Navigation | Tab or Shift-Tab | Switch between chat list and conversation | +| Conversation | Esc | Clear message selection first; then return to the chat list | +| Conversation | Left | Return to the chat list | +| Image preview | Esc or q / i / O | Close / reply / reveal original file | +| Navigation | g i / Ctrl-R | Show chat and folder IDs / refresh lists | +| Navigation | ? / s / a / q | Help / settings / accounts / quit | +| Composer | Enter | Send | +| Composer | Shift-Enter or Ctrl-J | Newline | +| Composer | Esc | Cancel reply first; then leave with draft kept | +| Editors | Left/Right, Home/End, Ctrl-A/Ctrl-E | Move cursor | +| Editors | Backspace/Delete, Ctrl-W, Ctrl-U | Delete character / previous word / clear | +| Chat filter | Enter / Esc | Open match / clear filter and leave | +| Overlays | Up/Down or k/j | Select or scroll | +| Overlays | Enter or Space | Apply setting, color, or account selection | +| Overlays | Esc or ? | Close | +| Search | Enter / Esc | Search or open result / close | +| Search | Tab / Ctrl-F | Change scope / edit query | +| Search | Ctrl-N / Ctrl-P | Next / previous result page | +| Search | Up/Down, PageUp/PageDown | Select result / move ten results | + +A number prefix such as `20k` works in the two navigation panes. For a single +key that jumps N messages, use `count` in [Lua configuration](Configuration.md). +`gg` is scoped to loaded history; it does not download every message back to the +start of a group. `G` reloads the latest page when reading older history. + +Changed defaults from earlier Termgram versions: conversation `/` now opens +local search; type bot `/commands` after `i`. Uppercase `O` now reveals files; +action cycling moves to `g n` / `g p`, and `o` expands an image preview. +`i` also works from the chat list and replies when a message is explicitly selected. Use the account picker with arrows and Enter; +digit keys in navigation are counts, and the old account-number hints are gone. +There is no separate hardcoded keyboard fallback after Lua resolution. diff --git a/docs/wiki/en/Pins.md b/docs/wiki/en/Pins.md new file mode 100644 index 0000000..6111ba1 --- /dev/null +++ b/docs/wiki/en/Pins.md @@ -0,0 +1,49 @@ +# Pinned chats and messages + +[简体中文](../zh-CN/Pins.md) · [Guide](Home.md) + +## Chat lists + +Select a chat and press `p` to pin/unpin it in the current folder. `Ctrl-K` and +`Ctrl-J` move it within that folder's pinned order. Main, Archive, and custom +folders have independent Telegram orders. Pins remain above ordinary chats +when messages arrive, and restart uses the last cached order. See [Folders](Folders.md) +for archive behavior and custom-folder inclusion rules. + +## Conversation pins + +Select a sent message with `j/k`, then press `p`. A confirmation overlay offers +the applicable Telegram options. In ordinary private chats, the default is +**Pin only for me**; the second option pins for both participants. In groups, +a new pin defaults to notifying members, with a silent option. Pinning a message +older than the known top pin is silent, following Desktop's older-message prompt. +Saved Messages does not need a participant option. Arrow keys select; Enter +confirms; Esc dismisses. Changes appear after Telegram accepts them. Missing +permissions or server errors remain visible and do not report success. + +Press `p` on a pinned message to confirm unpinning. This uses Telegram's normal +unpin operation. The confirmation identifies the message before any change. + +The conversation shows a compact pinned-message banner when a pin is available. +Press `P` to browse all pins, newest message first. `j/k` select, `Ctrl-N` / `Ctrl-P` +page, `Enter` opens the message and older context, and `Esc` returns. `p` unpins +the selection; `U` confirms unpinning all messages. `Ctrl-R` retries a refresh. +All bindings are configurable; the list uses the `pins` Lua context, and its +confirmation uses `overlay`. + +Opening a pin keeps the draft and detaches the reading position without marking +the entire conversation read. `G` returns to the latest history. Other clients' +pin/unpin and deletion updates refresh the banner/list. Pins and message previews +are fetched in bounded background requests, so history loading and live updates +continue while the list synchronizes. + +If Telegram requires a fresh synchronization, the open pin list reloads and +outdated message confirmations close. Drafts are kept. + +Cached pins can be browsed before connecting. The list labels cached coverage; +it may be incomplete because message retention is bounded. Server pages replace +that view once connected. Mutating pins requires a connection. + +Protocol references: [Telegram message pins](https://core.telegram.org/api/pin), +[pin options](https://core.telegram.org/method/messages.updatePinnedMessage). +Desktop behavior reference: [pin message dialogs](https://github.com/telegramdesktop/tdesktop/blob/4d4da471fbee771c10e173a83c003ba1728989f1/Telegram/SourceFiles/boxes/pin_messages_box.cpp). diff --git a/docs/wiki/en/Search.md b/docs/wiki/en/Search.md new file mode 100644 index 0000000..29be9b7 --- /dev/null +++ b/docs/wiki/en/Search.md @@ -0,0 +1,32 @@ +# Search cached messages + +[简体中文](../zh-CN/Search.md) · [Guide](Home.md) + +Press `/` in a conversation, or `Ctrl-F` in the chat list. Type a regular +expression and press Enter. Tab cycles through the open chat, selected folder, +and this account. Searching works offline and never requests Telegram history. +The header identifies the scope; the results report the cached message count and +date range. This is cache coverage, not a claim that the account's full history +has been downloaded. A range can contain gaps. + +Results are newest first, with 100 hits per page. Up/Down selects a result; +PageUp/PageDown moves ten rows; Enter opens it with cached neighboring messages. +Ctrl-N/Ctrl-P changes result pages, Ctrl-F returns to the query, and Esc closes +search. Press `/` again from the conversation to return to the retained results. +`G` returns from an old result to the latest messages. Opening a historical result +does not itself mark the chat's unseen history as read. + +Examples: `(?i)invoice` (case insensitive), `error.*timeout`, `^TODO`, or +`東京|上海`. Syntax follows the maintained Rust [regex crate](https://docs.rs/regex/latest/regex/#syntax). +Lookaround and backreferences are not supported. Invalid patterns display an +editable error; patterns are limited to 4096 bytes and compiled with memory limits. +Only message text is matched, including captions; attachment contents are not indexed. + +Search runs on a dedicated blocking worker with one active scan and one replaceable +pending request. Esc, a new query, or switching accounts cancels it. Deletions +and edits already persisted in the cache are reflected in new searches. A hit is +revalidated before opening; deleted or evicted hits ask you to run the search again. + +All controls are configurable in the `search` context. Actions are `open`, +`cancel`, `up`, `down`, `page_up`, `page_down`, `search_scope`, `search_query`, +`search_more`, and `search_previous`. diff --git a/docs/wiki/en/Synchronization.md b/docs/wiki/en/Synchronization.md new file mode 100644 index 0000000..b824d4a --- /dev/null +++ b/docs/wiki/en/Synchronization.md @@ -0,0 +1,37 @@ +# Cache and synchronization + +[简体中文](../zh-CN/Synchronization.md) · [Guide](Home.md) + +Termgram displays cached conversations before connecting to Telegram. Opening a +cached conversation reads its most recent local page while a network request +refreshes it. Messages remain visible during synchronization. Sending and server +operations wait for sign-in to finish. + +Updates continue while history, dialog and other requests run in the background. +The currently viewed group also receives periodic difference checks using +Telegram's requested interval. Leaving the conversation or unfocusing the +terminal stops active polling. Ordinary push updates and gap recovery continue. + +Each session has a separate `*.cache.sqlite3` database beside its session file. +The cache contains message text, chat metadata and downloaded attachment paths. +It does not contain authentication keys or user preferences. On Unix the file is +restricted to its owner. The database is not encrypted. + +The cache contains messages Termgram has received or loaded, not the account's +entire Telegram history. Up to 100,000 message records are retained per account; +older records are discarded. An old gap that Telegram can no longer replay +invalidates the affected local history, which is fetched again when viewed. + +To clear the cache, exit Termgram and remove that session's `.cache.sqlite3` +file. Media can be cleared separately by removing its `.media` directory. Keep +the `.session` file, `settings.conf` and `appearance.json` to preserve login and +preferences. Only one process may own a given account cache at a time. +The next launch reconnects and starts a fresh message cache. + +Full update batches and their covered cursor are committed in order. Restarting +after an interrupted write may replay updates, but must never skip uncommitted +messages. Session peer metadata is separate from this message checkpoint. + +See [Telegram's update protocol](https://core.telegram.org/api/updates) for the +rules used by the underlying Grammers update engine. No end-to-end latency +comparison against Telegram Desktop has been measured yet. diff --git a/docs/wiki/en/Terminal.md b/docs/wiki/en/Terminal.md new file mode 100644 index 0000000..db79097 --- /dev/null +++ b/docs/wiki/en/Terminal.md @@ -0,0 +1,44 @@ +# Terminal integration + +[简体中文](../zh-CN/Terminal.md) · [Guide](Home.md) + +Yazi's pinned terminal crates own input parsing, terminal capabilities, the TTY +writer, and restoration. Ratatui draws using that writer; Crossterm supplies its +output backend, not a second input reader. Paste, focus, mouse, resize, enhanced +key reports, and associated Unicode text enter one application event stream. + +Shift-Enter requires a terminal that reports it separately. Ctrl-J is the newline +fallback. Function keys or Alt combinations can also be intercepted by your +terminal or desktop; choose another binding in [Lua](Configuration.md). +Ctrl-L redraws the screen after graphical corruption. + +## Images + +Yazi chooses the graphics protocol. `ratatui-image` renders multiple inline +images and clips them while scrolling. Ghostty/Kitty can use Kitty graphics; +iTerm2/WezTerm can use inline images; Sixel is used where detected. Other terminals +fall back to Unicode half-blocks without an external image-overlay process. +Actual selection depends on reported capabilities and any multiplexer in between. + +Decoding applies EXIF orientation and retains upstream ICC-to-sRGB conversion. +Image decoding/encoding runs away from the UI thread. Only visible encoded +images are retained, and resizing or overlays clear affected image areas. +Animated stickers use a static thumbnail. For downloads and retry behavior, see +[Attachments](Attachments.md). + +## tmux + +By default Termgram uses tmux's reported capabilities. To opt into Yazi's +second-stage terminal probe, set this **in the shell environment before launch**: + +```sh +TERMGRAM_TMUX_PASSTHROUGH=1 tg +``` + +This calls upstream tmux setup, setting the pane's `allow-passthrough=all` and +the server's `input-buffer-size=104857600`. Those settings remain after Termgram +exits. This option is read before the Telegram credentials `.env` is loaded. + +The app restores terminal modes on normal exit, initialization failure and the +panic hook. One owner coordinates text and graphics cleanup. Upstream revisions, +licenses, and small adaptations are recorded in [vendor provenance](../../../vendor/README.md). diff --git a/docs/wiki/en/UX.md b/docs/wiki/en/UX.md new file mode 100644 index 0000000..c190bbe --- /dev/null +++ b/docs/wiki/en/UX.md @@ -0,0 +1,75 @@ +# Daily workflows + +[简体中文](../zh-CN/UX.md) · [Guide](Home.md) + +## Read and navigate + +The chat list and conversation share a wide window. Narrow windows show one +pane at a time. A focus border and selection marker show where keys will act; +Tab switches focus. Esc clears an explicit message selection before returning +from a conversation to the list. +Use `j/k` to select messages, arrows to scroll their rendered rows, and `G` to +return to the latest page. While you read earlier messages, new arrivals retain +your reading position and show a count instead of pulling the view down. + +`[`/`]` in the list switch [folders](Folders.md). `/` filters titles in that +folder. A [configured chat alias](Configuration.md) opens a stable Telegram ID +from All chats. `g i` shows the selected chat and folder IDs for configuration. + +Cached content appears first. The connection state and loading indicator describe +background reconciliation. Cached search and local preferences work without a +connection; sending and server operations need an authenticated connection. +The outgoing queue is bounded, and a failed send stays visible for retry. + +## Compose and reply + +Click the composer or press `i` to write. From the chat list, `i` continues the +current conversation, or restores the last chat for this Telegram account after +restart. If it is unavailable, the highlighted chat is used; an empty list shows +a synchronization hint. The composer shows a configurable ghost hint when empty, +without a second permanent row repeating the send key. Enter sends; Shift-Enter +or Ctrl-J inserts a newline. Bot commands are typed here, including their `/`. +Each open chat has its own in-memory draft. Esc preserves it; drafts are not +persisted across process restarts or account switches. + +Select a message and press `i` or `R` to reply, or use `R` with no selection for the +latest message. Clicking the composer preserves an existing reply draft without +creating a reply from the message selection. The composer shows the target. Esc cancels the reply first, +keeping text; another Esc returns to navigation. `r` opens a selected message's +reply target. Failed sends keep their content and reply target; activating a +failed outgoing message returns it to the composer for retry. + +## Files, links and mouse input + +Drop file paths from your desktop into an open conversation to upload them. +Composer text becomes the first file's caption and retains the reply target. +JPG/JPEG/PNG/WebP inputs are uploaded as Telegram photos; other files are sent +as documents. Photos, image documents and stickers preview inline. `O` reveals +the original in the system file manager; see [Attachments](Attachments.md). + +Click media to select it, then `o` for a larger preview or `i` to reply. Use +`g n` / `g p` to select actionable entries, then Enter to activate. Telegram +URL entities appear as selectable link rows, including hidden-text links. +Public `t.me`, `telegram.me`, and `tg://resolve` chat/message links open in-app; +private `t.me/c` and `tg://privatepost` links work for known groups. Other HTTP(S) +links open through the operating system. Invite and broadcast-channel links are +outside scope. URL, web-view, callback and game bot buttons are supported; +payment, password-gated, contact/location and peer-selection buttons identify +that a graphical client is required. + +Mouse support is optional: click a chat to open it, click a message or action to +select it, right-click a message to reply, and scroll the pane under the pointer. +Clicking the chat list or timeline leaves input mode and keeps the draft. Settings/account rows are also +clickable. Keyboard equivalents remain available. + +## Settings and accounts + +`s` opens automatic update checks, release channel, Enter download behavior, +and the message-ID column. `c` and `C` open [color pickers](Appearance.md). +These persist without rewriting Lua. Esc dismisses an overlay, returning focus +to the previous view. `?` shows effective bindings instead of a static cheat sheet. + +`a` opens the account picker; select an account or its add row and press Enter. +F2 cycles existing slots and F3 adds a slot, up to eight. Only the active account +has a running network worker. Sessions, messages, media, and color overrides are +isolated; switching clears in-memory drafts and searches from the previous account. diff --git a/docs/wiki/en/Updates.md b/docs/wiki/en/Updates.md new file mode 100644 index 0000000..34c2da4 --- /dev/null +++ b/docs/wiki/en/Updates.md @@ -0,0 +1,63 @@ +# Updates and releases + +[简体中文](../zh-CN/Updates.md) · [Guide](Home.md) + +Run `tg update` for the selected channel, `tg update --stable` for stable, or +`tg update --prerelease` to include prereleases. The stable channel selects the +highest stable `0.1.Z`; the prerelease channel selects the highest `0.1.Z` of +either type, so a newer stable release is not replaced by an older prerelease. + +Automatic checking runs in the background at most once per day for the selected +channel and shows a footer hint. Applying an update requires the explicit +command. `s` opens settings to disable checking or change channels. The updater +verifies `SHA256SUMS` from GitHub HTTPS delivery before replacing the executable. +On Windows replacement finishes after the updater exits. Another process can +keep the binary locked; a later `tg update` downloads a freshly verified copy. + +`tg --version` shows the version, commit, branch, OS, architecture and build +number. A `*` marks a source tree with non-ignored changes. Piped version output +is plain text; `NO_COLOR=1` or `TERM=dumb` also disables its styling. Source +archives without Git metadata show `unknown` for unavailable fields. + +## Cargo source installs + +Repeat the [Cargo installation command](Getting-Started.md) to rebuild from the +latest source, keeping the same `--branch`, `--rev` or `--root` options when used. +A fixed `--rev` stays on that commit until you change it. Cargo tracks the Git +source even though the manifest's base package version remains `0.1.0`. + +Git source builds display `0.1.Z` using their first-parent commit height. Check +the displayed commit to identify the source; Cargo's checkout can show a local +branch such as `master` or `HEAD` instead of the requested source branch. Archives +without Git metadata fall back to the manifest +version; CI can set an explicit version. + +`tg update` downloads a prebuilt GitHub release; it does not run Cargo or follow +your source branch. Use the Cargo command to continue following source changes. + +## Maintainer release workflow + +CI checks formatting, strict Clippy, tests, installers, and six native packages: +Linux x86_64/ARM64, macOS Intel/Apple silicon, Windows x64/ARM64. Default-branch +builds receive application API credentials from the `TELEGRAM_API_ID` and +`TELEGRAM_API_HASH` Actions secrets; PR builds do not receive them. + +Every successful default-branch commit is published as `0.1.Z`, where `Z` is its +first-parent height. Ordinary commits produce prereleases. A subject beginning +with `release:` produces a stable release; for example: + +```sh +git commit --allow-empty -m "release: stable" +git push +``` + +Publication reuses tested artifacts and includes installers and `SHA256SUMS`. +Failed checks do not publish. Interrupted publications are retried on a later +push; existing tags are never moved. Automatic publication begins at height 2. +The legacy asset labels `linux`, `macos`, and `windows` mean Linux x86_64, +Apple-silicon macOS, and Windows x64; other architectures have explicit suffixes. + +Application API credentials can be recovered from a compiled binary; they are +not a user's Telegram session. User phone numbers, login codes, passwords and +session files are never supplied to CI. This project is unofficial and is not +affiliated with Telegram. diff --git a/docs/wiki/zh-CN/Appearance.md b/docs/wiki/zh-CN/Appearance.md new file mode 100644 index 0000000..683c7d9 --- /dev/null +++ b/docs/wiki/zh-CN/Appearance.md @@ -0,0 +1,53 @@ +# 外观 + +[English](../en/Appearance.md) · [指南首页](Home.md) + +在聊天列表或聊天内容区按 `c` 设置聊天颜色;在列表按 `C` 设置当前文件夹颜色。 +Up/Down 选择带名称的终端颜色,Enter 应用,Esc 取消。列表选中标记和选中样式不依赖 +颜色本身,因此颜色不是唯一的状态提示。 + +聊天行和聊天标题默认使用终端前景色。文件夹标题从六种 ANSI 调色板颜色中稳定 +选取,终端主题决定实际 RGB 值;这些颜色不假定终端一定采用深色背景。 + +优先级为:内置默认值 → Lua → 应用内覆盖。选择 **Follow configuration** 移除 +覆盖并重新跟随配置。**Terminal default** 则是明确使用终端前景色,可以覆盖 Lua +中指定的彩色默认值。 + +```lua +return { + colors = { + chats = { [-1001234567890] = "cyan" }, + folders = { [2] = "yellow" }, + }, +} +``` + +可用值:`default`、`black`、`red`、`green`、`yellow`、`blue`、`magenta`、`cyan`、 +`gray`、`dark_gray`、`light_red`、`light_green`、`light_yellow`、`light_blue`、 +`light_magenta`、`light_cyan`、`white`。`g i` 显示聊天 ID 和当前文件夹 ID。 + +应用内覆盖保存在 `settings.conf` 同目录的 `appearance.json`,按真实 Telegram +账号 ID 隔离。切换账号或清理消息缓存不会混用或删除配色。保存复用现有原子写入 +逻辑;格式错误会明确报错,不会悄悄覆盖损坏的文件。调色板不会改写 Lua 配置或 +服务端文件夹颜色。 + +## Nerd Font 图标 + +Nerd Font 支持需要主动开启。安装 [Nerd Font 字体](https://www.nerdfonts.com/font-downloads), +在终端配置中选择其 **Nerd Font Mono** 变体,再在 `config.lua` 中添加配置并重启 Termgram: + +```lua +return { + nerd_font = true, +} +``` + +使用 v3+ 字体。图标用于区分聊天类型、文件夹、Archive、置顶和附件,文字标签、按键提示 +与终端配色仍会保留。Mono 变体让图标位于单个终端单元格内。通过 SSH 或 tmux 使用时, +需要在实际显示会话的本地终端设置字体。Termgram 使用终端选定的字体,不会安装字体或 +修改终端偏好。 + +默认 `nerd_font = false` 使用普通文字显示,置顶标记为 `^`。如果图标显示为方框或与邻近 +文字重叠,将此选项关闭即可恢复。配置错误通过现有 Lua 加载流程显示。 + +字体参考:[Nerd Fonts 字体变体](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting)。 diff --git a/docs/wiki/zh-CN/Attachments.md b/docs/wiki/zh-CN/Attachments.md new file mode 100644 index 0000000..bf5a29f --- /dev/null +++ b/docs/wiki/zh-CN/Attachments.md @@ -0,0 +1,36 @@ +# 附件与文件预览 + +[English](../en/Attachments.md) · [指南首页](Home.md) + +用 `j/k` 选择附件消息,或点击它,再按大写 `O`(`Shift-O`),即可在系统文件管理器 +中显示文件。这里是单次按键,不依赖长按计时;终端上报的重复按键事件会被忽略。 +同一附件下载期间重复操作不会启动额外下载。 + +已有原尺寸图片预览或下载文件时直接复用,否则先下载原始附件,再自动显示。 +动态贴纸的缩略图只用于预览,`O` 下载其原始文件。缓存文件已被删除时会重新下载。 +失败原因显示在状态区,可再次操作重试。 + +macOS 在 Finder 中选中文件,Windows 在资源管理器中选中文件;Linux 用 `xdg-open` +打开所在目录,能否选中文件取决于文件管理器。不会执行附件本身。即使设置中 +Enter 下载行为选择 **Keep in cache**,`O` 仍表示明确的“显示文件”操作。 + +单击媒体只选择并显示可用按键。`o` 在终端展开图片或贴纸,Esc 关闭大图,`i` 回复, +`O` 定位原始文件。选择不会直接激活链接、机器人按钮或文件管理器操作。 +`g n` / `g p` 切换可操作项,Enter 激活。Lua 动作为 `preview`、`next_action`、 +`previous_action` 和 `reveal`;大图使用 `preview` 上下文。 + +使用 Telegram 媒体 ID 和请求编号校验结果,避免旧下载覆盖已编辑或已删除的附件。 +文件映射随消息缓存保存,重启后可复用。完成的文件位于 +`<会话文件名>.media//`。每个目录最多保留 512 个已完成文件, +目标体积上限为 1 GiB,超过后清理较旧文件。刚下载的文件即使单独超过体积上限也会 +保留,之后的清理仍可能移除它。需要长期保存的文件应复制到缓存目录之外。 + +下载中的文件复用维护中的 `tempfile` 库,与已完成文件分开。取消下载会清理临时 +文件,进程中断遗留的文件会在下一次开始传输前清理。同一账号缓存只允许一个进程 +持有,未结束的媒体任务会保留锁直到清理完成,避免两个客户端同时推进同一缓存的 +同步进度。 + +照片、图片文档和贴纸在消息流内预览,只请求可见预览,应用最多同时等待两个预览下载, +全部网络传输共享有界工作任务。动态 TGS/WebM 贴纸显示 Telegram 的栅格缩略图并标注为 +静态预览;失败预览选中后可按 `o` 或 Enter 重试。Ctrl-L 重绘,协议说明见[终端](Terminal.md)。 +拖入本地路径上传的方法见[日常操作](UX.md)。 diff --git a/docs/wiki/zh-CN/Configuration.md b/docs/wiki/zh-CN/Configuration.md new file mode 100644 index 0000000..915dce0 --- /dev/null +++ b/docs/wiki/zh-CN/Configuration.md @@ -0,0 +1,112 @@ +# Lua 配置 + +[English](../en/Configuration.md) · [指南首页](Home.md) + +## 加载配置 + +把 `config.lua` 放在 `settings.conf` 旁,或用 `TERMGRAM_CONFIG` 指定路径。 +重启后生效,可以从[配置示例](../../../examples/config.lua)开始。配置返回一个 Lua table, +支持 table、字符串、数学和 UTF-8 辅助函数,不提供文件系统、进程或插件接口。 +配置错误会显示提示,并继续使用默认快捷键。 + +```lua +return { + chats = { work = -1001234567890 }, + ghost_text = "{send} 发送 · {newline} 换行", + nerd_font = false, + keymap = { + { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + { context = "conversation", on = { "g", "w" }, run = "jump work" }, + { context = "compose", on = { "" }, run = "newline" }, + { context = "compose", on = { "" }, run = "send" }, + }, +} +``` + +上下文包括 `global`、`chats`、`conversation`、`compose`、`input`(登录与聊天过滤) +、`overlay`、`preview`、`pins` 和 `search`。具体上下文优先于全局绑定。同一上下文中配置相同按键会替换默认绑定; +`run = "noop"` 删除绑定。组合键用独立按键列表表示,例如 `{ "g", "w" }`。 +配置会检查前缀冲突。组合键一秒后过期,Escape 可以取消尚未完成的组合键。 +按键表示法沿用 Yazi,例如 ``、``、``、``、``。 + +聊天别名使用稳定的 Telegram 数字 ID,超级群为 `-100…` 格式;目标需要已经出现在 +本地聊天列表中。绑定可选填 `desc` 作为帮助页说明。帮助页读取当前生效的绑定, +使用方向键滚动。 + +默认 `j/k` 在聊天之间或实际消息之间移动,`20k` 向上移动 20 条消息,必要时继续 +加载更早的历史。`G` 或 End 回到最新消息;`gg` 或 Home 到当前已加载窗口的最早消息。 +方向键按显示行滚动,PageUp/PageDown 每次滚动十行。`i` 输入,`R` 回复,`r` 跳转到 +回复目标,`/` 在聊天列表中过滤,Tab 切换面板,`s` 设置,`a` 账号,`?` 帮助。 +输入框内的普通文字不会触发导航组合键或数字前缀。 + +给 `message_up` 配置 `count = 20`,即可用一个快捷键向上跳 20 条。`count` 默认 1, +范围 1–9999,支持 `up`、`down`、`message_up`、`message_down`、`page_up`、`page_down`。 +输入的数字前缀会乘以配置值,最终限制在 9999;其他动作不接受数量参数。 + +输入框占位提示中的 `{send}`、`{newline}`、`{cancel}` 会替换为实际快捷键。 +`ghost_text = ""` 隐藏提示。应用内偏好单独保存,不会改写 Lua 文件。 + +按 `g i` 显示当前聊天 ID,可用来配置别名。 + +终端使用 Nerd Font Mono(v3+)时,可设置 `nerd_font = true`,启用聊天、文件夹、 +归档、置顶和附件图标;默认 `false`。字体选择和回退方法见[外观](Appearance.md)。 + +## 文件路径 + +`config.lua`、`settings.conf`、`appearance.json`、`navigation.json` 位于配置目录; +`navigation.json` 按 Telegram 账号 ID 记录上次聊天,不保存草稿文字。会话、消息库和媒体位于 +数据目录。默认值如下: + +| 系统 | 配置目录 | 数据目录 | +| --- | --- | --- | +| Linux | `$XDG_CONFIG_HOME/termgram` 或 `~/.config/termgram` | `$XDG_DATA_HOME/termgram` 或 `~/.local/share/termgram` | +| macOS | `~/Library/Application Support/dev.termgram.Termgram` | 相同 | +| Windows | `%APPDATA%/termgram/Termgram/config` | `%LOCALAPPDATA%/termgram/Termgram/data` | + +账号 1 使用 `termgram.session`,其他槽位使用它旁边的 +`accounts/.account-N`,各自追加 `.cache.sqlite3` 数据库与 `.media` +目录。会话文件包含账号凭据,消息缓存与媒体是本地明文。清理方法见[缓存与同步](Synchronization.md)。 + +| 环境变量 | 用途 | +| --- | --- | +| `TELEGRAM_API_ID`、`TELEGRAM_API_HASH` | 源码构建的应用凭据;环境或 `.env` 优先于内置值 | +| `TERMGRAM_SESSION` | 指定账号 1 会话路径,其他槽位据此派生 | +| `TERMGRAM_CONFIG` | 指定 Lua 文件,在启动前的 shell 中设置 | +| `TERMGRAM_TMUX_PASSTHROUGH=1` | 启用终端透传设置,见[终端](Terminal.md) | + +Lua 在凭据 `.env` 之前加载,因此 `TERMGRAM_CONFIG` 必须已存在于进程环境。 +旧的 `TUIGRAM_SESSION` 和已有 TUIGram 默认会话仍被识别,以保留登录状态。 +更改会话路径不会移动设置或颜色文件。 + +Lua 源码限制 64 KiB、VM 内存 8 MiB、约一百万条指令。未知字段、动作、别名和同上下文 +前缀冲突会报错;整个无效配置回退为默认值。不提供任意插件 API 或热重载。 + +## 动作表 + +把动作放在合适的上下文。自定义 `global` 绑定作为后备,具体上下文的绑定或组合键前缀 +优先。`desc` 自定义帮助文字;`noop` 删除当前上下文的绑定后,已有全局绑定可能重新生效。 + +| 动作 | 常用上下文与行为 | +| --- | --- | +| `quit`、`redraw`、`next_account`、`add_account` | 全局退出、重绘、账号控制 | +| `help`、`settings`、`accounts` | 导航;打开或切换浮层 | +| `open`、`cancel`、`focus` | 按上下文激活、关闭、切换面板或二维码 | +| `up`、`down`、`page_up`、`page_down` | 列表选择、显示行滚动或搜索选择,支持 `count` | +| `message_up`、`message_down` | 会话消息光标,支持 `count` | +| `oldest`、`latest` | 首末聊天,或已加载历史起点/最新会话 | +| `compose`、`send`、`newline` | 进入草稿或回复会话中明确选中的消息 / 发送 / 换行 | +| `preview` | 展开所选图片或贴纸;大图通过 `preview` 上下文配置按键 | +| `home`、`end`、`left`、`right`、`backspace`、`delete`、`clear`、`delete_word` | 编辑器 | +| `filter`、`refresh`、`chat_info` | 导航;标题过滤、列表刷新、显示 ID | +| `folder_previous`、`folder_next` | 聊天列表文件夹 | +| `pin`、`pin_up`、`pin_down`、`archive` | 聊天列表;官方置顶排序和归档 | +| `reply`、`reply_target`、`open_link`、`next_action`、`previous_action`、`reveal` | 会话操作 | +| `chat_color`、`folder_color` | 外观选择器 | +| `pin`、`pins` | 会话;确认置顶/取消置顶,打开置顶列表 | +| `pins_more`、`pins_previous`、`unpin_all` | 置顶消息浮层(`pins` 上下文) | +| `search` | 打开本地搜索 | +| `search_scope`、`search_query`、`search_more`、`search_previous` | 搜索浮层 | +| `jump ALIAS` | 打开配置的稳定聊天 ID,返回导航状态 | +| `noop` | 移除指定上下文中的绑定 | + +默认按键见[快捷键](Keybindings.md),`colors` 表及应用内覆盖优先级见[外观](Appearance.md)。 diff --git a/docs/wiki/zh-CN/Development.md b/docs/wiki/zh-CN/Development.md new file mode 100644 index 0000000..94b4720 --- /dev/null +++ b/docs/wiki/zh-CN/Development.md @@ -0,0 +1,112 @@ +# 开发与架构 + +[English](../en/Development.md) · [指南首页](Home.md) + +遵循 [AGENTS.md](../../../AGENTS.md):先理解问题、上游实现及调用方,决定设计、完成实现后 +再验证。本地计划与调查输出放在忽略的 `dev-notes/`,用户文档在这里同步维护两种语言。 +使用描述性功能分支与可构建的原子 Conventional Commits;功能拆分为独立 PR,有依赖时明确基底分支。 + +## 构建与验证 + +环境和凭据见[快速入门](Getting-Started.md),使用固定工具链与提交的锁文件: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features +cargo build --locked --release +python3 scripts/export-wiki.py --check +``` + +只保留验证可观察行为和集成边界的少量回归测试。现有覆盖包括 reducer、Unicode 输入、 +终端渲染、缓存重启/重放、会话跨进程写入、更新恢复、文件夹规则、Lua 路由、本地正则搜索、 +附件身份与淘汰。使用合成数据,不使用真实账号凭据或私人消息。 +不要求 TDD,不引入通用测试框架,不重复上游解析器或数据库测试。 +格式与 lint 面向根工作区,保留 vendored 上游代码原有风格。 + +CI 在六个平台上通过 `cargo install --git` 安装检出的 Git 提交,验证包选择、仓库内依赖 +补丁、锁文件、Cargo 安装记录和已安装程序的版本,再打包该可执行文件;通过 Cargo +目录 `target/cargo-install` 复用产物,与工作区构建及其 build-script 元数据分开。 +版本与发布见[更新](Updates.md)。自动化数据不能证明 +与 Telegram 的真实延迟一致,也不能代替目标平台文件管理器的人工验证。 +这些验证需要获准的测试账号与对应系统,不应默认给其他人发消息来测试。 + +## 所有权和边界 + +```mermaid +flowchart LR + T[单个 Yazi 输入读取器] --> A[App reducer] + A --> L[本地协调器] + L --> C[SQLite 消息缓存] + L --> N[Grammers 工作任务] + N --> L + L --> A + A --> R[Ratatui 与预览渲染] + L --> S[有界本地搜索] + N --> M[有界媒体传输] +``` + +- `src/app.rs` 管理选择、焦点、草稿和请求代次,功能 reducer 在 `src/app/`,渲染在 `src/ui/`。 +- `src/keymap.rs` 加载声明式 Lua,把 Yazi 按键映射为动作。上下文、数量、组合键和提示使用 + 同一组绑定。`config.lua` 归用户所有,托管设置和账号外观另存。 +- `src/telegram/local.rs` 在联网前打开账号缓存,处理本地请求、限制认证前的待执行命令并 + 持久保存更新。`src/cache.rs` 负责迁移、历史覆盖范围、修订与保留策略。 +- `src/telegram/mod.rs` 管理客户端、更新流和任务生命周期,`requests.rs` 限制普通 RPC, + 传输另有额度。较慢的列表、历史或 peer RPC 不占用更新消费路径。 +- `src/search.rs` 用维护中的 regex 引擎扫描本地文本,一个运行中的阻塞扫描加一个可替换 + 待执行请求,支持代次校验、取消和游标分页,不请求远端历史。 +- `src/telegram/media_cache.rs` 管理完整文件和 `tempfile` 临时文件, + `src/app/attachments.rs` 连接消息选择与下载/定位操作。 + `src/terminal.rs` 负责模式及恢复,`src/media.rs` 通过同一输出流协调图形。 + +## 同步约束 + +缓存展示不能等待认证、完整聊天列表或全历史扫描。更新接收 future 在处理命令和完成事件 +期间保持存活,取消重建它可能丢失进行中的恢复。当前聚焦群组使用 Grammers 更新引擎和 +服务端要求的 channel difference 间隔;停止活跃轮询时,一般推送与缺口恢复继续工作。 + +持久游标属于完整且已覆盖的更新批次。消息、编辑、删除标记与游标一起提交;会话游标推进 +不代表消息已缓存。重启恢复应用自己的游标。过长差异先使相关历史失效,再接收替代数据。 +重放新消息不能重复增加未读数。 + +聊天预览以真正的 top message ID 为准,不使用当前历史窗口最后一条。进行中的旧快照不能 +恢复已删消息或覆盖新编辑、已读状态。精确的缓存分页边界避免把稀疏回复/搜索上下文当成 +完整历史。切换账号取消旧工作,并拒绝过期请求代次。 + +每个账号消息缓存只能有一个进程所有者,文件传输与清理共享该锁生命周期。 +底层 session 格式仍支持串行化 SQLite 写入,但两个完整客户端不能独立推进同一个消息缓存。 +升级内置 SQLite 并检查上游 WAL-reset 修复前,保留当前 rollback journal。 + +## 复用决策 + +| 上游 | 复用或调研内容 | 本地职责 | +| --- | --- | --- | +| Yazi `9203fd2604f867ab5ec18f24203b918975c4c00a` | 终端生命周期、解析、能力探测、TTY、公开按键标准化;固定版本依赖 | 小型适配层与可见性补丁,不创建第二个输入读取器 | +| Codex `78245b47af2a7aafcabe025828ceecca69db4df1` | 上下文优先级、组合键取消、实际按键提示、输入框 UX | 设计参考,不 fork 整个 TUI 或复制插件运行时 | +| Telegram Desktop `4d4da471fbee771c10e173a83c003ba1728989f1` | 活跃群恢复、原生过滤规则、本地优先存储 | 通过 Telegram API/Grammers 对齐行为,不复制 Desktop 存储格式 | +| Grammers 0.10.0 | MTProto 传输与更新顺序 | 批次/游标/活跃群的局部补丁,保留上游许可证 | +| libSQL / regex / mlua / tempfile / ratatui-image | 数据库、正则、有界 Lua 求值、临时文件、内联渲染 | 应用 schema、资源限制、命令与 UX | + +参考源码:[Yazi](https://github.com/sxyazi/yazi/tree/9203fd2604f867ab5ec18f24203b918975c4c00a)、 +[Codex TUI](https://github.com/openai/codex/tree/78245b47af2a7aafcabe025828ceecca69db4df1/codex-rs/tui)、 +[Telegram Desktop](https://github.com/telegramdesktop/tdesktop/tree/4d4da471fbee771c10e173a83c003ba1728989f1)、 +[Telegram 更新协议](https://core.telegram.org/api/updates)、 +[文件夹协议](https://core.telegram.org/api/folders)。 + +TDLib 提供维护中的客户端/数据库体系,但在这里采用它将替换认证、消息映射、媒体和现有 +Grammers 集成。本次保留 Grammers,把所需修改限制在有说明的 SDK 边界;如果以后无法 +以小范围维护保证更新正确性,再重新评估。 + +升级时统一更新所有 Yazi Git revision。优先发行依赖或不可变 Git revision,不能依赖 +机器旁边的本地 checkout。复制源码必须保留许可证,并在 +[vendor/README.md](../../../vendor/README.md) 记录仓库、版本、原路径与修改。 +替换后删除废弃基础设施,避免长期维护两条路径。 + +## UX 维护 + +提示来自实际绑定;空输入框用 ghost text,浮层接管输入并保留退出路径。 +切换视图保留草稿与阅读位置。明确范围:加载窗口不是全量历史,缓存搜索不是服务器搜索, +加载中不等于没有结果。颜色辅助焦点标记,不单独承担状态含义。 + +英文与简体中文一起更新。[Wiki 导出流程](../README.md) 校验页面对应关系和内部链接, +再将源目录映射为 GitHub Wiki 文件名,无需引入文档框架。 diff --git a/docs/wiki/zh-CN/Folders.md b/docs/wiki/zh-CN/Folders.md new file mode 100644 index 0000000..d3a835e --- /dev/null +++ b/docs/wiki/zh-CN/Folders.md @@ -0,0 +1,40 @@ +# Telegram 文件夹 + +[English](../en/Folders.md) · [指南首页](Home.md) + +Termgram 读取 Telegram 中已配置的文件夹,包括共享文件夹。文件夹顺序、置顶聊天、 +包含与排除规则会同步,并按账号缓存。使用 Telegram Desktop 等官方客户端创建或 +编辑文件夹后,Termgram 会自动更新。 + +在聊天列表中用 `[`、`]` 切换上一个、下一个文件夹,边框显示名称与位置。对应的 +Lua 动作为 `chats` 上下文中的 `folder_previous` 和 `folder_next`。`/` 只过滤当前 +文件夹。切换文件夹保留已打开的聊天、阅读位置和草稿。`jump alias` 会从“全部聊天”或“归档” +打开目标,因此目标不在当前文件夹时也能跳转。参阅[配置](Configuration.md)。 + +显式排除优先,其次是显式包含与置顶,最后才是动态规则。支持联系人、非联系人、 +机器人、群组,以及未读、静音、归档筛选。机器人属于独立类别。按照 Telegram +Desktop 的规则,静音但有未读提及的聊天仍可进入文件夹,除非已归档;没有独立静音 +设置的聊天继承账号的通知默认设置。其他客户端产生的已读通知会更新“未读”文件夹。 +需要重试时可按 `Ctrl-R` 刷新文件夹和聊天列表。 + +Termgram 目前提供私聊和群聊,暂不展示广播频道,包括文件夹中的广播频道。 + +## 归档 + +Archive(ID `1`)是独立文件夹;All chats(ID `0`)不展示归档聊天。 +选中聊天后按 `e`(`archive`)归档,在 Archive 中按相同按键取消归档。 +自定义文件夹仍遵循自己的包含规则与 `exclude_archived` 规则;其他客户端的变更会自动同步。 + +## 置顶聊天 + +在聊天列表按 `p`(`pin`)置顶或取消置顶,`Ctrl-K` / `Ctrl-J`(`pin_up` / `pin_down`) +上移或下移置顶聊天。主列表、Archive 和每个自定义文件夹分别使用独立的服务端顺序。 +`^` 标记表示置顶,其他聊天的新消息不会挤到置顶聊天前面。排序后仍选中同一聊天, +置顶顺序会缓存,重启后立即可见。 + +与 Telegram Desktop 一样,在自定义文件夹内取消置顶后,聊天仍保留为显式包含项。 +修改置顶会保留服务端的标题、颜色、共享文件夹属性与筛选规则。置顶与归档操作需要 +联网,等待服务端确认后更新;服务端数量限制或权限错误会明确显示。本地外观设置 +不会修改 Telegram 的共享文件夹数据。 + +实现参考:[Telegram 文件夹协议](https://core.telegram.org/api/folders)。 diff --git a/docs/wiki/zh-CN/Getting-Started.md b/docs/wiki/zh-CN/Getting-Started.md new file mode 100644 index 0000000..8e7145d --- /dev/null +++ b/docs/wiki/zh-CN/Getting-Started.md @@ -0,0 +1,144 @@ +# 快速入门 + +[English](../en/Getting-Started.md) · [指南首页](Home.md) + +## 安装发行版 + +提供 Linux x86_64/ARM64、macOS Intel/Apple silicon、Windows x64/ARM64 原生构建。 +Linux/macOS 需要 Bash、`curl`、`tar`,以及 `sha256sum` 或 `shasum`;Windows 需要 +PowerShell 5.1 或更高版本。终端至少 40 × 10,建议使用 80 × 24 或更大窗口。 + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh | bash +``` + +默认安装到 `~/.local/bin`。如果希望先审阅脚本,可把相同 URL 下载为文件,阅读后执行 +`bash install.sh`。选择预发布版或更改目录: + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh \ + | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash +``` + +Windows PowerShell: + +```powershell +$installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' +& ([scriptblock]::Create([string]$installer)) +# 可选参数: +& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" +``` + +Windows 默认目录为 `%LOCALAPPDATA%\Programs\Termgram\bin`,也可先查看 `$installer` +内容。安装器会校验发行版 `SHA256SUMS`、选择原生架构,并在需要时提示加入 PATH 的目录; +不会提权或自行修改 PATH。版本选择与升级规则见[更新](Updates.md)。 + +## 登录并发送消息 + +1. 执行 `tg`。官方发行包内置项目的 Telegram 应用凭据;源码构建需要下文所述的自有凭据。 +2. 输入国际格式手机号、验证码以及可选的两步验证密码。密码输入会掩码显示。 + 手机号页按 Tab 使用二维码,再用已登录的 Telegram 在 **设置 → 设备 → 连接桌面设备** 扫码。 +3. 用 `j/k` 或方向键选择聊天,Enter 打开。 +4. 按 `i` 进入输入框,输入消息后 Enter 发送,Ctrl-J 换行。 + Esc 退出输入,在当前账号的本次运行中保留草稿。 +5. `G` 回到最新消息,`?` 打开帮助;导航状态按 `q` 退出。 + +二维码会自动轮换。Tab 切换紧凑字符块与较大的全单元格显示,Esc 返回手机号登录。 +F2 切换已有账号,F3 添加账号;登录页和错误页也能使用。 +以上均为默认按键,可通过 [Lua 配置](Configuration.md) 修改。 + +## 使用 Cargo 从源码安装 + +Linux x86_64/ARM64、macOS Intel/Apple silicon、Windows x64/ARM64 使用同一条 Cargo +命令。Cargo 按仓库锁文件编译选定的 Git 源码,使用 release profile 安装 `tg`, +Windows 下为 `tg.exe`。 + +### 首次准备构建工具 + +通过 [rustup](https://rustup.rs/) 安装 Rust,并准备 Git 和平台的 C 编译器、链接器。 +Lua 与 SQLite 随应用编译,无需单独安装它们的系统包。 + +| 平台 | 原生构建工具 | +| --- | --- | +| Linux | C 编译器、链接器、make 和 Git;Debian/Ubuntu 可执行 `sudo apt-get install build-essential git` | +| macOS | Xcode Command Line Tools:`xcode-select --install` | +| Windows | Visual Studio C++ 构建工具和 Windows SDK,选择对应 CPU 的 MSVC 工具;ARM64 需要 ARM64 组件。参阅 [rustup MSVC 配置](https://rust-lang.github.io/rustup/installation/windows-msvc.html)。 | + +安装项目固定的编译器,不改变系统默认 Rust 工具链: + +```sh +rustup toolchain install 1.98.0 --profile minimal +``` + +### 一条命令安装或升级 + +以下命令可直接用于 Bash、Zsh 和 PowerShell: + +```sh +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram +``` + +它安装仓库默认分支;再次执行即可升级到该分支的最新源码。`+1.98.0` 明确选择编译器, +在仓库外执行也有效。下载后的仓库工具链文件不会替换已经启动的 Cargo 进程。 + +默认安装位置为 Linux/macOS 的 `~/.cargo/bin/tg`,Windows 的 +`%USERPROFILE%\.cargo\bin\tg.exe`,需要把该目录加入 PATH。自定义 `CARGO_HOME` 或 +Cargo 安装根目录设置会改变位置;`--root DIR` 安装到 `DIR/bin`,参数应为 bin 的父目录。 +若旧版 `tg` 优先被找到,用 Bash/Zsh 的 `type -a tg` 或 PowerShell 的 +`Get-Command tg -All` 查看,并移除 PATH 中过期的可执行文件。 + +安装其他分支时添加 `--branch BRANCH`;固定提交则改用 `--rev COMMIT`,把占位文字替换 +为所需 Git 引用。PR 功能在合并前需要选择该 PR 的源码分支。此安装方式需要保留 Git URL; +不要改成 `cargo install termgram`,后者会查找 crates.io 上的包。 + +`cargo uninstall termgram` 卸载 Cargo 管理的程序,保留 Termgram 配置与账号数据。 +使用过 `--root` 时,更新和卸载也应提供相同目录。`--force` 可以重编同一提交,例如修改了 +内置应用凭据后;`--target-dir DIR` 可以保留构建产物,供后续源码更新复用。 + +更多选项与安装记录规则见 [Cargo 安装文档](https://doc.rust-lang.org/cargo/commands/cargo-install.html)。 +Cargo 源码升级与 `tg update` 的区别见[更新](Updates.md)。 + +### Telegram 应用凭据 + +Git 源码安装需要在 [my.telegram.org/apps](https://my.telegram.org/apps) 创建自己的应用, +获取 API ID 与 API hash。它们标识客户端;启动 `tg` 后仍需登录 Telegram 账号。 +源码安装不会获得项目 GitHub Actions 的发行版凭据。 + +启动前设置运行时环境变量。Bash/Zsh: + +```sh +export TELEGRAM_API_ID=123456 +export TELEGRAM_API_HASH=your_api_hash +tg +``` + +PowerShell: + +```powershell +$env:TELEGRAM_API_ID = '123456' +$env:TELEGRAM_API_HASH = 'your_api_hash' +tg +``` + +请替换示例值,运行时变量优先于程序内置默认值。应用也会读取工作目录或其父目录中的 +`.env`。若需要一个不依赖运行时变量的个人可执行文件,可在构建环境中设置 +`TERMGRAM_EMBEDDED_API_ID` 与 `TERMGRAM_EMBEDDED_API_HASH` 后执行 Cargo;这些应用 +凭据会成为可执行文件的一部分,账号会话不会被内置。 + +### 安装本地 checkout + +开发时克隆仓库,可把 `.env.example` 复制为 `.env` 设置运行时凭据,再安装当前源码: + +```sh +git clone https://github.com/iebb/termgram.git +cd termgram +cargo install --locked --path . --bin tg +``` + +此时使用仓库固定的工具链。`cargo run --locked --release` 可以直接运行而不安装。 +不要提交 `.env` 或会话数据库。 + +路径与环境变量见[配置](Configuration.md),图片/按键问题见[终端集成](Terminal.md), +离线与缓存行为见[缓存与同步](Synchronization.md)。 diff --git a/docs/wiki/zh-CN/Home.md b/docs/wiki/zh-CN/Home.md new file mode 100644 index 0000000..a1bc15b --- /dev/null +++ b/docs/wiki/zh-CN/Home.md @@ -0,0 +1,30 @@ +# Termgram 使用指南 + +[English](../en/Home.md) · [选择语言](../Home.md) + +Termgram 使用 Telegram 的 MTProto 用户 API,访问已有私聊和群组。最多支持八个账号槽位, +同一时间连接一个账号。重新连接时,可以先阅读本地缓存。 + +| 我想…… | 对应文档 | +| --- | --- | +| 安装、登录、从源码构建 | [快速入门](Getting-Started.md) | +| 学习快捷键、迁移旧绑定 | [快捷键](Keybindings.md) | +| 改键、配置群聊直达、修改输入提示 | [Lua 配置](Configuration.md) | +| 阅读、回复、发文件、切换账号 | [日常操作](UX.md) | +| 切换 Telegram 文件夹 | [文件夹](Folders.md) | +| 置顶聊天或消息、浏览置顶 | [置顶](Pins.md) | +| 搜索本地缓存消息 | [本地正则搜索](Search.md) | +| 设置聊天和文件夹颜色 | [外观](Appearance.md) | +| 预览文件、在文件管理器中定位 | [附件](Attachments.md) | +| 理解离线行为、清理缓存 | [缓存与同步](Synchronization.md) | +| 排查图片、按键或 tmux 问题 | [终端集成](Terminal.md) | +| 更新程序、了解发行规则 | [更新](Updates.md) | +| 参与开发和维护 | [开发指南](Development.md) | + +文档描述的是其来源版本;用 `tg --version` 查看已安装版本。程序里的 `?` 帮助会显示 +实际生效的 Lua 绑定。文档提供英文和简体中文;当前界面使用英文,输入框占位提示可自定义。 + +当前范围不包含广播频道、秘密聊天、通话、故事、回应、贴纸/GIF 选择器、全服务器消息搜索、 +联系人管理、创建聊天、群管理、投票、通知,以及主动编辑、删除或转发消息。 +其他客户端产生的消息编辑和删除会同步。文件夹在官方客户端创建与编辑。 +支持的链接和机器人按钮见[日常操作](UX.md)。 diff --git a/docs/wiki/zh-CN/Keybindings.md b/docs/wiki/zh-CN/Keybindings.md new file mode 100644 index 0000000..2522e91 --- /dev/null +++ b/docs/wiki/zh-CN/Keybindings.md @@ -0,0 +1,75 @@ +# 快捷键 + +[English](../en/Keybindings.md) · [指南首页](Home.md) + +下表是默认值,`?` 显示当前 Lua 配置实际生效的绑定。大写字母表示 Shift 加该字母。 +`g g`、`g i`、`g n`、`g p` 是依次按下;未完成的组合键一秒后过期,Esc 可取消。 +按键作用于焦点面板或浮层,输入框里的普通字母仍然是文字。 + +| 上下文 | 按键 | 动作 | +| --- | --- | --- | +| 全局 | Ctrl-C / Ctrl-L | 退出 / 重绘 | +| 全局 | F2 / F3 | 下一个账号 / 添加账号 | +| 登录 | Enter / Esc | 提交 / 重新开始手机号登录 | +| 登录 | Tab 或 Shift-Tab | 开始二维码登录或切换二维码显示 | +| 聊天列表 | j/k 或 Down/Up | 选择下一个/上一个聊天 | +| 聊天列表 | Enter 或 Right | 打开所选聊天 | +| 聊天列表 | i | 在当前聊天输入;没有打开聊天时恢复本账号上次聊天 | +| 聊天列表 | G / g g | 过滤列表的最后/第一个聊天 | +| 聊天列表 | PageDown/PageUp | 移动十个聊天 | +| 聊天列表 | / | 在当前文件夹中过滤标题 | +| 聊天列表 | Ctrl-F | 打开本地正则搜索 | +| 聊天列表 | [ / ] | 上一个/下一个 Telegram 文件夹 | +| 聊天列表 | c / C | 聊天/文件夹颜色选择器 | +| 聊天列表 | p | 在当前文件夹中置顶/取消置顶 | +| 聊天列表 | Ctrl-K / Ctrl-J | 上移/下移置顶聊天 | +| 聊天列表 | e | 归档/恢复所选聊天 | +| 会话 | j/k 或 ]/[ | 选择下一条/上一条消息 | +| 会话 | 20k | 向上跳 20 条,必要时加载更早的分页 | +| 会话 | Up/Down | 按显示行滚动 | +| 会话 | PageUp/PageDown | 滚动十个显示行 | +| 会话 | G 或 End | 回到最新消息并跟随新消息 | +| 会话 | g g 或 Home | 当前加载窗口中最早的消息 | +| 会话 | i | 回复明确选中的消息;未选择时输入消息 | +| 会话 | Enter | 激活选中项;无选择时进入输入 | +| 会话 | R / r | 回复选中/最新消息;跳到它引用的消息 | +| 会话 | o | 展开所选图片或贴纸 | +| 会话 | g n / g p | 下一个/上一个可操作项 | +| 会话 | l | 打开选中或首个支持的链接 | +| 会话 | O | 在系统文件管理器中定位附件 | +| 会话 | / / c | 本地正则搜索 / 聊天颜色 | +| 会话 | p / P | 置顶/取消置顶所选消息;打开置顶列表 | +| 置顶消息 | Enter / Esc | 打开所选消息/关闭 | +| 置顶消息 | Ctrl-N / Ctrl-P | 下一页/上一页 | +| 置顶消息 | p / U | 确认取消置顶/取消全部置顶 | +| 置顶消息 | Ctrl-R | 刷新置顶 | +| 导航 | Tab 或 Shift-Tab | 切换列表与会话焦点 | +| 会话 | Esc | 先清除消息选择,再返回聊天列表 | +| 会话 | Left | 返回聊天列表 | +| 大图预览 | Esc 或 q / i / O | 关闭 / 回复 / 显示原始文件 | +| 导航 | g i / Ctrl-R | 显示聊天和文件夹 ID / 刷新列表 | +| 导航 | ? / s / a / q | 帮助 / 设置 / 账号 / 退出 | +| 输入框 | Enter | 发送 | +| 输入框 | Shift-Enter 或 Ctrl-J | 换行 | +| 输入框 | Esc | 先取消回复,再退出输入并保留草稿 | +| 编辑器 | Left/Right、Home/End、Ctrl-A/Ctrl-E | 移动光标 | +| 编辑器 | Backspace/Delete、Ctrl-W、Ctrl-U | 删字符 / 前一个词 / 清空 | +| 聊天过滤 | Enter / Esc | 打开匹配聊天 / 清空并退出过滤 | +| 浮层 | Up/Down 或 k/j | 选择或滚动 | +| 浮层 | Enter 或 Space | 应用设置、颜色或账号选择 | +| 浮层 | Esc 或 ? | 关闭 | +| 搜索 | Enter / Esc | 搜索或打开结果 / 关闭 | +| 搜索 | Tab / Ctrl-F | 更改范围 / 编辑查询 | +| 搜索 | Ctrl-N / Ctrl-P | 下一页 / 上一页结果 | +| 搜索 | Up/Down、PageUp/PageDown | 选择结果 / 移动十个结果 | + +`20k` 这类数字前缀在两个导航面板中生效。如果希望一个快捷键直接跳 N 条, +在 [Lua 配置](Configuration.md) 中设置 `count`。 +`gg` 只到当前已加载历史的顶部,不会下载整个群从建立以来的全部消息; +阅读旧历史时,`G` 会重新加载最新页。 + +旧版默认值迁移:会话中的 `/` 现在打开本地搜索,机器人 `/命令` 请先按 `i` 再输入。 +大写 `O` 定位文件,可操作项切换改为 `g n` / `g p`,`o` 展开图片预览。 +`i` 也可从聊天列表进入输入;会话中明确选中消息时则回复该消息。账号选择使用方向键和 Enter; +导航中的数字用作数量前缀,已去掉旧的账号数字直达提示。 +Lua 解析之后不再有另一套硬编码按键兜底。 diff --git a/docs/wiki/zh-CN/Pins.md b/docs/wiki/zh-CN/Pins.md new file mode 100644 index 0000000..e94f2cb --- /dev/null +++ b/docs/wiki/zh-CN/Pins.md @@ -0,0 +1,39 @@ +# 聊天与消息置顶 + +[English](../en/Pins.md) · [指南首页](Home.md) + +## 聊天列表 + +选中聊天后按 `p`,在当前文件夹内置顶或取消置顶;`Ctrl-K` / `Ctrl-J` 调整置顶顺序。 +主列表、Archive 和自定义文件夹使用独立的 Telegram 服务端顺序。其他聊天收到新消息 +不会超过置顶项;重启后先读取缓存的顺序。归档和文件夹包含规则见[文件夹](Folders.md)。 + +## 会话内置顶 + +用 `j/k` 选中已发送的消息,再按 `p`。确认浮层按聊天类型提供 Telegram 对应选项: +普通私聊默认“仅为自己置顶”,另一选项为双方置顶;群内新的置顶默认通知成员, +也可以静默置顶。消息早于已知的最新置顶时,按 Desktop 的旧消息提示静默置顶。 +Saved Messages 不需要选择另一参与者。用方向键选择、Enter 确认、Esc 关闭。 +服务端接受后才更新状态;权限不足或服务端错误会明确显示。 + +在已置顶消息上按 `p` 可确认取消置顶,使用 Telegram 的常规取消置顶操作。 +确认浮层会先展示消息信息。 + +有置顶消息时,会话顶部显示一行紧凑提示。按 `P` 打开所有置顶消息,按消息从新到旧 +排列。`j/k` 选择,`Ctrl-N` / `Ctrl-P` 翻页,Enter 打开该消息及较早上下文,Esc 返回。 +`p` 取消所选消息置顶,`U` 确认取消全部置顶,`Ctrl-R` 刷新或重试。 +所有按键均可配置:列表使用 Lua `pins` 上下文,确认浮层使用 `overlay`。 + +打开置顶消息会保留草稿和阅读位置,不会把整个会话标为已读;`G` 回到最新历史。 +其他客户端的置顶、取消置顶、删除操作会更新列表与顶部提示。后台请求限制并发与 +每页大小,浏览置顶时仍继续加载历史和接收实时更新。 + +如果 Telegram 要求重新同步,打开的置顶列表会重新加载,过期消息的确认浮层会关闭, +草稿仍会保留。 + +联网前也可以浏览已缓存的置顶消息,界面会标明缓存范围;消息缓存有保留上限, +因此缓存列表可能不完整。联网后用服务端分页结果更新。修改置顶需要联网。 + +协议参考:[Telegram 消息置顶](https://core.telegram.org/api/pin)、 +[置顶选项](https://core.telegram.org/method/messages.updatePinnedMessage)。 +Desktop 行为参考:[置顶提示框](https://github.com/telegramdesktop/tdesktop/blob/4d4da471fbee771c10e173a83c003ba1728989f1/Telegram/SourceFiles/boxes/pin_messages_box.cpp)。 diff --git a/docs/wiki/zh-CN/Search.md b/docs/wiki/zh-CN/Search.md new file mode 100644 index 0000000..14d2bf1 --- /dev/null +++ b/docs/wiki/zh-CN/Search.md @@ -0,0 +1,27 @@ +# 搜索本地缓存消息 + +[English](../en/Search.md) · [指南首页](Home.md) + +在聊天内容区按 `/`,或在聊天列表按 `Ctrl-F`,输入正则表达式后按 Enter。Tab 在当前 +聊天、当前文件夹、当前账号三个范围间切换。搜索可以离线使用,不会向 Telegram +请求历史记录。标题显示范围,结果显示缓存消息数与日期范围。这些数字只表示缓存 +覆盖情况,不代表已下载完整历史;日期范围内也可能有缺口。 + +结果从新到旧排列,每页 100 条命中。Up/Down 选择结果,PageUp/PageDown 移动十行, +Enter 打开消息及其缓存前后文。Ctrl-N/Ctrl-P 翻页,Ctrl-F 返回查询输入,Esc 关闭。 +在聊天中再次按 `/` 可回到保留的结果。`G` 从历史结果回到最新消息。打开历史搜索 +结果本身不会把聊天中尚未阅读的消息全部标为已读。 + +示例:`(?i)invoice` 忽略大小写,`error.*timeout` 匹配两个词之间的内容,`^TODO` +匹配开头,`东京|上海` 匹配任意一个词。语法采用维护中的 Rust +[regex 库](https://docs.rs/regex/latest/regex/#syntax),不支持环视和反向引用。错误的 +表达式会显示可修正的错误提示;表达式最多 4096 字节,编译内存也有上限。匹配消息 +文字和附件说明,不索引附件内部内容。 + +扫描在独立后台工作线程中进行,同时最多一个扫描和一个可替换的待执行请求。Esc、 +修改查询或切换账号会取消扫描。重新搜索会反映已写入缓存的编辑和删除。打开命中项 +前会再次检查缓存;消息已删除或被清理时,会提示重新搜索。 + +所有操作都可在 Lua 的 `search` 上下文中配置:`open`、`cancel`、`up`、`down`、 +`page_up`、`page_down`、`search_scope`、`search_query`、`search_more`、 +`search_previous`。 diff --git a/docs/wiki/zh-CN/Synchronization.md b/docs/wiki/zh-CN/Synchronization.md new file mode 100644 index 0000000..bbf114d --- /dev/null +++ b/docs/wiki/zh-CN/Synchronization.md @@ -0,0 +1,29 @@ +# 缓存与同步 + +[English](../en/Synchronization.md) · [指南首页](Home.md) + +Termgram 会先显示本地缓存的聊天列表,再连接 Telegram。打开已有缓存的聊天时, +先读取最近一页本地消息,同时在后台获取最新内容。同步期间仍可阅读缓存。 +发送消息等服务端操作会等待登录完成。 + +拉取历史、刷新聊天列表等请求不会阻塞实时消息。对于正在查看的群聊,还会按照 +Telegram 返回的间隔主动补取差异。离开聊天或终端失去焦点后,会停止这项主动轮询; +普通推送和差异恢复继续运行。 + +每个会话文件旁都有独立的 `*.cache.sqlite3` 数据库,保存消息正文、聊天元数据和 +已下载附件的路径。登录凭据和用户设置分别保存。Unix 系统下数据库文件只允许当前 +用户访问;数据库本身不加密。 + +缓存只包含 Termgram 已收到或加载过的消息,不代表账号的完整历史。每个账号最多 +保留 100,000 条消息记录,旧记录会被清理。如果 Telegram 已无法重放某段过旧的差异, +对应的缓存会失效,并在查看时重新拉取。 + +清理缓存时,先退出 Termgram,再删除对应会话的 `.cache.sqlite3` 文件。保留 +`.session`、`settings.conf` 和 `appearance.json` 即可保留登录状态与偏好。媒体文件可 +单独删除对应的 `.media` 目录。同一账号缓存同时只允许一个进程持有。下次启动会重新建立消息缓存。 + +完整更新批次及其同步进度按顺序提交。写入中断后可能重放已收到的更新,但不会用 +超前的进度跳过尚未提交的消息。Telegram 会话中的 peer 元数据与消息缓存进度分开管理。 + +底层 Grammers 更新引擎遵循 [Telegram 更新协议](https://core.telegram.org/api/updates)。 +目前尚未进行与 Telegram Desktop 的端到端延迟对比测试。 diff --git a/docs/wiki/zh-CN/Terminal.md b/docs/wiki/zh-CN/Terminal.md new file mode 100644 index 0000000..7263e24 --- /dev/null +++ b/docs/wiki/zh-CN/Terminal.md @@ -0,0 +1,37 @@ +# 终端集成 + +[English](../en/Terminal.md) · [指南首页](Home.md) + +固定版本的 Yazi 终端库负责输入解析、能力探测、TTY 输出和模式恢复。Ratatui 使用同一个 +输出通道;Crossterm 仅作为输出后端,不另起输入读取器。粘贴、焦点、鼠标、窗口大小、 +增强按键报告和关联 Unicode 文字进入同一个应用事件流。 + +Shift-Enter 需要终端能够单独报告,不能区分时用 Ctrl-J 换行。功能键或 Alt 组合也可能 +被终端或桌面拦截,可在 [Lua](Configuration.md) 中换键。显示损坏时可用 Ctrl-L 重绘。 + +## 图片 + +Yazi 选择图形协议,`ratatui-image` 负责多个内联图片及滚动时的裁剪。 +Ghostty/Kitty 可以使用 Kitty 图形协议,iTerm2/WezTerm 可以使用内联图片,检测到支持时 +使用 Sixel;其他终端回退到 Unicode 半块字符,不需要额外图片覆盖进程。 +实际选择取决于终端能力报告和中间的复用器。 + +解码保留上游 ICC 到 sRGB 转换并处理 EXIF 方向;解码和编码不在 UI 线程中进行。 +只保留可见图片的编码缓存,调整窗口或打开浮层时清理对应图像区域。 +动态贴纸使用静态缩略图。下载与重试说明见[附件](Attachments.md)。 + +## tmux + +默认使用 tmux 自身报告的能力。如果需要 Yazi 第二阶段探测,必须在启动前设置到 +**shell 环境**: + +```sh +TERMGRAM_TMUX_PASSTHROUGH=1 tg +``` + +这会调用上游 tmux 设置,把窗格的 `allow-passthrough` 设为 `all`,服务器的 +`input-buffer-size` 设为 `104857600`。退出 Termgram 后这些设置仍保留。 +读取该选项时,Telegram 凭据 `.env` 尚未加载。 + +正常退出、初始化失败和 panic hook 都会恢复终端模式。文本与图形清理由单一所有者协调。 +上游版本、许可证与局部修改记录在 [vendor 说明](../../../vendor/README.md)。 diff --git a/docs/wiki/zh-CN/UX.md b/docs/wiki/zh-CN/UX.md new file mode 100644 index 0000000..889f588 --- /dev/null +++ b/docs/wiki/zh-CN/UX.md @@ -0,0 +1,57 @@ +# 日常操作 + +[English](../en/UX.md) · [指南首页](Home.md) + +## 阅读和导航 + +宽窗口同时显示聊天列表和会话;窄窗口一次显示一个面板。焦点边框与选择标记表明按键 +将作用于哪里。Tab 切换焦点,Esc 先清除明确的消息选择,再从会话返回列表。 +`j/k` 选择消息,方向键按实际显示行滚动,`G` 回到最新页。 +阅读较早消息时,新消息保留当前阅读位置并显示计数,不会强行把视图拉到底部。 + +列表里的 `[`/`]` 切换[文件夹](Folders.md),`/` 过滤当前文件夹的标题。 +[预设聊天别名](Configuration.md) 按稳定的 Telegram ID 从 All chats 打开目标; +`g i` 显示聊天和文件夹 ID,便于配置。 + +先显示缓存,连接状态与加载提示说明后台同步进度。本地搜索和本地偏好可以离线使用; +发送以及服务器操作需要完成认证并联网。待执行命令队列有上限,发送失败会保留可重试消息。 + +## 输入和回复 + +点击输入框或按 `i` 输入。聊天列表中的 `i` 会继续当前会话;没有打开的会话时,恢复 +当前 Telegram 账号上次打开的聊天,重启后也有效。目标不可用时选择列表中高亮的聊天, +空列表会提示等待同步。空白输入框显示可配置的 ghost text,不再常驻另一行重复发送按键。 +Enter 发送,Shift-Enter 或 Ctrl-J 换行,机器人命令也在这里输入,包括开头的 `/`。 +每个已打开聊天保留独立的内存草稿;Esc 保留草稿,但重启或切换账号不会持久保存草稿。 + +选中消息按 `i` 或 `R` 回复;未选择时 `R` 回复最新消息。输入框显示回复目标。 +点击输入框会保留已有回复草稿,不会因为消息选择而新建回复。 +第一次 Esc 取消回复关系并保留文字,第二次 Esc 返回导航。`r` 跳到消息引用的目标。 +发送失败会保留文字和回复目标;激活失败的发出消息,可回到输入框重试。 + +## 文件、链接和鼠标 + +从桌面拖入文件路径可上传文件。输入框中的文字会作为第一个文件的说明,并保留回复目标。 +JPG/JPEG/PNG/WebP 作为 Telegram 照片上传,其他文件作为文档。 +照片、图片文档和贴纸在消息流内预览;`O` 在系统文件管理器中定位原件,详见[附件](Attachments.md)。 + +点击媒体选择后,`o` 打开大图、`i` 回复。用 `g n` / `g p` 选择可操作项,Enter 激活。Telegram URL 实体会显示为可选择的链接行, +包括隐藏在文字后的链接。公开 `t.me`、`telegram.me`、`tg://resolve` 聊天/消息链接 +在应用内打开;私有 `t.me/c`、`tg://privatepost` 支持已知群组。其他 HTTP(S) 链接由 +系统打开。邀请链接和广播频道链接不在支持范围内。 +支持 URL、web-view、callback、game 机器人按钮;支付、密码保护、联系人/位置和选择 +聊天对象的按钮会提示需要图形客户端。 + +鼠标是可选入口:单击聊天打开,单击消息或操作项选择,右键消息回复,滚轮滚动指针所在面板。 +点击聊天列表或消息区会退出输入模式并保留草稿。 +设置与账号行也可点击;对应键盘操作始终可用。 + +## 设置与账号 + +`s` 打开自动检查更新、发行通道、Enter 下载行为、消息 ID 列设置。 +`c`、`C` 打开[颜色选择器](Appearance.md)。这些偏好会持久保存,不改写 Lua。 +Esc 关闭浮层并恢复先前视图;`?` 显示当前生效的绑定。 + +`a` 打开账号选择器,用方向键选中账号或添加行,再按 Enter。 +F2 轮换已有槽位,F3 添加槽位,最多八个。只有当前账号运行网络工作任务。 +会话、消息、媒体、颜色覆盖按账号隔离;切换会清空前一个账号的内存草稿和搜索状态。 diff --git a/docs/wiki/zh-CN/Updates.md b/docs/wiki/zh-CN/Updates.md new file mode 100644 index 0000000..6cba9d9 --- /dev/null +++ b/docs/wiki/zh-CN/Updates.md @@ -0,0 +1,51 @@ +# 更新与发行 + +[English](../en/Updates.md) · [指南首页](Home.md) + +`tg update` 使用所选通道,`tg update --stable` 选择稳定版, +`tg update --prerelease` 包含预发布版。稳定通道选最高的稳定 `0.1.Z`;预发布通道在 +两类发行中选最高的 `0.1.Z`,因此不会用较旧预发布版覆盖更新的稳定版。 + +后台对所选通道每天最多自动检查一次,在底部轻量提示。实际安装必须执行更新命令。 +`s` 设置中可以关闭检查或更改通道。更新器通过 GitHub HTTPS 获取并验证 `SHA256SUMS`, +再替换可执行文件。Windows 在更新进程退出后完成替换;其他进程可能占用程序, +再次执行 `tg update` 会重新下载并校验。 + +`tg --version` 显示版本、提交、分支、系统、架构和构建号。提交号后的 `*` 表示源码树 +有非忽略的改动。重定向时输出纯文本;`NO_COLOR=1` 或 `TERM=dumb` 也能关闭版本信息着色。 +没有 Git 元数据的源码包对缺失字段显示 `unknown`。 + +## Cargo 源码安装的更新 + +再次执行 [Cargo 安装命令](Getting-Started.md) 即可从最新源码重编;使用过 +`--branch`、`--rev` 或 `--root` 时保留相应选项。固定 `--rev` 会一直停留在该提交, +需要主动改为其他提交才能更新。即使 manifest 的包版本仍是 `0.1.0`,Cargo 也会记录 Git 来源。 + +Git 源码构建按 first-parent 提交高度显示 `0.1.Z`,通过显示的提交号确认源码;Cargo 的 +checkout 可能显示本地的 `master` 或 `HEAD`,不一定是指定的源码分支。没有 Git 元数据的源码包回退到 manifest +版本,CI 可以指定版本覆盖值。 + +`tg update` 下载 GitHub 上预编译的发行版,不会执行 Cargo 或跟随源码分支。 +要继续跟随源码更新,请使用 Cargo 命令。 + +## 维护者发行流程 + +CI 检查格式、严格 Clippy、测试、安装器,并构建六个原生包:Linux x86_64/ARM64、 +macOS Intel/Apple silicon、Windows x64/ARM64。默认分支构建从 Actions 的 +`TELEGRAM_API_ID`、`TELEGRAM_API_HASH` secrets 获取应用凭据,PR 构建不会获取它们。 + +每个成功的默认分支提交发布为 `0.1.Z`,`Z` 是 first-parent 提交高度。 +普通提交为预发布版,标题以 `release:` 开头则发布稳定版,例如: + +```sh +git commit --allow-empty -m "release: stable" +git push +``` + +发布复用通过验证的构建产物,包含安装器与 `SHA256SUMS`。检查失败不会发布,中断的发布 +在后续推送时重试,不移动已有标签。自动发布从高度 2 开始。 +旧资产名 `linux`、`macos`、`windows` 分别表示 Linux x86_64、Apple-silicon macOS、 +Windows x64;其他架构使用明确后缀。 + +编译进程序的应用 API 凭据可以被提取,它们不是用户的 Telegram 会话。 +用户手机号、验证码、密码、会话文件不进入 CI。Termgram 是非官方项目,与 Telegram 无隶属关系。 diff --git a/examples/config.lua b/examples/config.lua new file mode 100644 index 0000000..bfe551c --- /dev/null +++ b/examples/config.lua @@ -0,0 +1,15 @@ +-- Save as config.lua beside settings.conf, or set TERMGRAM_CONFIG. +-- Settings are declarative: return a table. See docs/wiki/en/Configuration.md. +return { + chats = { + -- work = -1001234567890, -- stable Telegram chat ID + }, + ghost_text = "{send} to send", + nerd_font = false, -- true when the terminal uses a Nerd Font Mono (v3+) + keymap = { + -- { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + -- { context = "conversation", on = { "g", "w" }, run = "jump work" }, + -- { context = "compose", on = { "" }, run = "newline" }, + -- { context = "compose", on = { "" }, run = "send" }, + }, +} diff --git a/scripts/export-wiki.py b/scripts/export-wiki.py new file mode 100644 index 0000000..29e26e8 --- /dev/null +++ b/scripts/export-wiki.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Validate reviewed Markdown and export it to GitHub Wiki's flat page names. + +Only inline Markdown links are used by these guides. Keep GitHub's renderer; +this adapter does not render Markdown, contact GitHub, or push a repository. +""" + +import argparse +from pathlib import Path +import re +import subprocess +from urllib.parse import quote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs/wiki" +REPOSITORY = "https://github.com/iebb/termgram" +LINK = re.compile(r"\]\(([^\s)]+)\)") + + +def pages(): + english = {path.name for path in (SOURCE / "en").glob("*.md")} + chinese = {path.name for path in (SOURCE / "zh-CN").glob("*.md")} + if not english or english != chinese: + raise ValueError(f"English/Chinese page mismatch: {english ^ chinese}") + paths = [SOURCE / "Home.md"] + sorted(SOURCE.glob("*/*.md")) + return { + path: "-".join(path.relative_to(SOURCE).with_suffix("").parts) + for path in paths + } + + +def rewrite(text, source, names, revision): + def link(match): + target = match[1] + if urlsplit(target).scheme or target.startswith(("#", "//")): + return match[0] + path, separator, anchor = target.partition("#") + resolved = (source.parent / path).resolve() + if not resolved.is_relative_to(ROOT) or not resolved.exists(): + raise ValueError(f"Broken local link in {source.relative_to(ROOT)}: {target}") + if resolved in names: + url = names[resolved] + else: + kind = "tree" if resolved.is_dir() else "blob" + url = f"{REPOSITORY}/{kind}/{quote(revision, safe='/')}/{resolved.relative_to(ROOT).as_posix()}" + return f"]({url}{separator}{anchor})" + + return LINK.sub(link, text) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true", help="validate without writing") + mode.add_argument("--output", type=Path, help="export into a directory or wiki checkout") + parser.add_argument("--source-ref", help="source Git revision used by repository links") + args = parser.parse_args() + revision = args.source_ref or subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + names = pages() + rendered = { + name: rewrite(path.read_text(encoding="utf-8"), path, names, revision) + for path, name in names.items() + } + for path in [ROOT / "README.md", SOURCE / "README.md"]: + rewrite(path.read_text(encoding="utf-8"), path, names, revision) + if args.check: + print(f"Validated {len(names)} wiki pages, language parity, and local links") + return + output = args.output.resolve() + if output == ROOT or output == SOURCE or SOURCE.is_relative_to(output) or output.is_relative_to(SOURCE): + raise ValueError("Choose an export directory outside the documentation source") + output.mkdir(parents=True, exist_ok=True) + source_url = f"{REPOSITORY}/tree/{quote(revision, safe='/')}/docs/wiki" + for name, content in rendered.items(): + (output / f"{name}.md").write_text( + f"{content.rstrip()}\n\n---\n[Documentation source / 文档来源]({source_url})\n", + encoding="utf-8", + ) + sidebar = ["[Languages / 语言](Home)", ""] + for locale, title in [("en", "English"), ("zh-CN", "简体中文")]: + sidebar.extend([f"**{title}**", ""]) + for path, name in names.items(): + if path.parent.name == locale: + title = path.read_text(encoding="utf-8").splitlines()[0].removeprefix("# ") + sidebar.append(f"- [{title}]({name})") + sidebar.append("") + (output / "_Sidebar.md").write_text("\n".join(sidebar), encoding="utf-8") + print(f"Exported {len(names) + 1} pages to {output}; review the diff before publishing") + + +if __name__ == "__main__": + try: + main() + except ValueError as error: + raise SystemExit(str(error)) from error diff --git a/src/app.rs b/src/app.rs index aed412b..4d4db9b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,12 @@ //! Pure, single-owner application state and transitions. +mod appearance; +mod attachments; +mod composition; +mod message_pins; +mod pins; +mod search; + use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -10,7 +17,8 @@ use yazi_term::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use crate::{ config::{DownloadBehavior, MAX_ACCOUNTS, Settings}, event::{AppEvent, AuthPrompt, ConnectionStatus, NetworkEvent, TelegramCommand}, - input::{KeyAction, TextInput, key_action}, + input::{KeyAction, TextInput}, + keymap::Context, model::{ Attachment, AttachmentKind, Chat, ChatId, Delivery, Message, MessageLink, ReplyInfo, sanitize_terminal_line, sanitize_terminal_text, @@ -147,7 +155,12 @@ pub enum Mode { #[default] Navigate, Compose, + Preview, Filter, + Search, + PinnedMessages, + PinPrompt, + Colors, Help, Settings, Accounts, @@ -188,7 +201,15 @@ pub struct App { pub focus: Focus, pub connection: ConnectionStatus, pub user_name: Option, + pub account_user_id: Option, + pub appearance: crate::appearance::Preferences, + navigation: composition::Navigation, + pub color_picker: Option, pub chats: Vec, + pub folders: Vec, + pub folder_id: i32, + pub pins: pins::State, + pub message_pins: message_pins::State, /// Position in the filtered chat list, never a persistent model identity. pub selected_chat: usize, pub active_chat_id: Option, @@ -199,6 +220,7 @@ pub struct App { /// and inline bot buttons, so selection cannot be message-only. pub selected_action: usize, pub filter: TextInput, + pub search: search::State, pub narrow_conversation: bool, pub should_quit: bool, pub status_message: Option, @@ -241,14 +263,23 @@ pub struct App { mode_before_settings: Mode, mode_before_accounts: Mode, force_redraw: bool, + pub keymap: crate::keymap::Keymap, + pub help_scroll: usize, next_pending_id: i32, next_history_request_id: u64, active_history_request: Option<(ChatId, u64)>, + history_changes: BTreeMap>, + history_read_max: i32, + older_motion: Option<(ChatId, u64, usize)>, + browsing_older: bool, + changed_dialogs: BTreeSet, active_reply_request: Option, history_target_message: Option, read_ack_pending: BTreeSet, refresh_dialogs_pending: bool, - downloading_attachments: BTreeSet<(ChatId, i32)>, + downloading_attachments: BTreeMap<(ChatId, i32), u64>, + next_download_request_id: u64, + reveal_after_download: BTreeSet<(ChatId, i32)>, downloaded_attachments: BTreeMap<(ChatId, i32), PathBuf>, pending_telegram_link: Option, /// Chats reached through links are kept until a dialog snapshot contains @@ -264,6 +295,7 @@ pub struct App { account_hit_regions: Vec<(u16, u16, u16, usize)>, /// Frame-local pane bounds used to route wheel events by pointer location. chat_pane_region: Option<(u16, u16, u16, u16)>, + composer_region: Option<(u16, u16, u16, u16)>, conversation_pane_region: Option<(u16, u16, u16, u16)>, } @@ -277,13 +309,22 @@ impl Default for App { focus: Focus::Chats, connection: ConnectionStatus::Connecting, user_name: None, + account_user_id: None, + appearance: crate::appearance::Preferences::default(), + navigation: composition::Navigation::default(), + color_picker: None, chats: Vec::new(), + folders: vec![crate::folders::Folder::all()], + folder_id: 0, + pins: pins::State::default(), + message_pins: message_pins::State::default(), selected_chat: 0, active_chat_id: None, messages: BTreeMap::new(), selected_message: None, selected_action: 0, filter: TextInput::new(), + search: search::State::default(), narrow_conversation: false, should_quit: false, status_message: None, @@ -315,14 +356,23 @@ impl Default for App { mode_before_settings: Mode::Navigate, mode_before_accounts: Mode::Navigate, force_redraw: false, + keymap: crate::keymap::Keymap::default(), + help_scroll: 0, next_pending_id: -1, next_history_request_id: 1, active_history_request: None, + history_changes: BTreeMap::new(), + history_read_max: 0, + older_motion: None, + browsing_older: false, + changed_dialogs: BTreeSet::new(), active_reply_request: None, history_target_message: None, read_ack_pending: BTreeSet::new(), refresh_dialogs_pending: false, - downloading_attachments: BTreeSet::new(), + downloading_attachments: BTreeMap::new(), + next_download_request_id: 1, + reveal_after_download: BTreeSet::new(), downloaded_attachments: BTreeMap::new(), pending_telegram_link: None, linked_chat_ids: BTreeSet::new(), @@ -331,6 +381,7 @@ impl Default for App { settings_hit_regions: Vec::new(), account_hit_regions: Vec::new(), chat_pane_region: None, + composer_region: None, conversation_pane_region: None, } } @@ -407,6 +458,13 @@ impl App { self.available_update.as_deref() } + #[must_use] + pub fn sync_target(&self) -> Option { + (self.terminal_focused && self.screen == Screen::Main && self.focus == Focus::Conversation) + .then_some(self.active_chat_id) + .flatten() + } + pub fn update(&mut self, event: AppEvent) -> Vec { match event { AppEvent::Key(key) => self.handle_key(&key), @@ -422,6 +480,14 @@ impl App { } } AppEvent::Tick => { + if self.keymap.expire() + && self + .status_message + .as_deref() + .is_some_and(|status| status.starts_with("Keys: ")) + { + self.status_message = None; + } self.tick = self.tick.wrapping_add(1); Vec::new() } @@ -437,6 +503,7 @@ impl App { self.settings_hit_regions.clear(); self.account_hit_regions.clear(); self.chat_pane_region = None; + self.composer_region = None; self.conversation_pane_region = None; } @@ -451,25 +518,331 @@ impl App { } pub fn handle_key(&mut self, key: &KeyEvent) -> Vec { - let editing = matches!(self.screen, Screen::Auth(_)) - || (self.screen == Screen::Main && matches!(self.mode, Mode::Compose | Mode::Filter)); - if editing && let Some(text) = key.text(&mut [0; 4]) { - // Yazi resolves keyboard-layout modifiers and Kitty associated text. - // This is typed text, not a paste: never interpret it as an upload path. + use crate::keymap::{Context, Resolution}; + let context = if matches!(self.screen, Screen::Auth(_)) { + Context::Input + } else { + match self.mode { + Mode::Compose => Context::Compose, + Mode::Preview => Context::Preview, + Mode::Filter => Context::Input, + Mode::Search => Context::Search, + Mode::PinnedMessages => Context::Pins, + Mode::PinPrompt | Mode::Help | Mode::Settings | Mode::Accounts | Mode::Colors => { + Context::Overlay + } + Mode::Navigate if self.focus == Focus::Chats => Context::Chats, + Mode::Navigate => Context::Conversation, + } + }; + match self.keymap.feed(context, key) { + Resolution::Action { run, count } => { + if run == "reveal" && key.kind == yazi_term::event::KeyEventKind::Repeat { + return Vec::new(); + } + if self + .status_message + .as_deref() + .is_some_and(|status| status.starts_with("Keys: ")) + { + self.status_message = None; + } + return self.run_binding(&run, count); + } + Resolution::Pending(hint) => { + self.status_message = (!hint.is_empty()).then(|| format!("Keys: {hint}")); + return Vec::new(); + } + Resolution::Unbound => {} + } + if (matches!(context, Context::Compose | Context::Input) + || (context == Context::Search && self.search.editing)) + && key.kind != yazi_term::event::KeyEventKind::Release + && let Some(text) = key.text(&mut [0; 4]) + { return text .chars() .filter(|c| !c.is_control()) .flat_map(|c| self.handle_action(KeyAction::Character(c))) .collect(); } - key_action(key).map_or_else(Vec::new, |action| self.handle_action(action)) + Vec::new() + } + + #[allow(clippy::too_many_lines)] + fn run_binding(&mut self, run: &str, count: usize) -> Vec { + self.older_motion = None; + if let Some(commands) = self.pin_binding(run, count) { + return commands; + } + if self.mode == Mode::Search + && let Some(commands) = self.search_binding(run, count) + { + return commands; + } + if let Some(alias) = run.strip_prefix("jump ") { + if let Some(id) = self.keymap.chats.get(alias).copied() { + if let Some(index) = self.chats.iter().position(|chat| chat.id == id) { + self.filter.clear(); + self.folder_id = i32::from(self.chats[index].membership.archived); + if self.folder_id == 1 && !self.folders.iter().any(|folder| folder.id == 1) { + self.folders.push(crate::folders::Folder::archive()); + } + self.mode = Mode::Navigate; + self.preserve_chat_selection(Some(id)); + return self.open_selected_chat(); + } + self.status_message = Some(format!( + "Chat alias {alias} is not in cached conversations yet" + )); + } + return Vec::new(); + } + match run { + "archive" => return self.toggle_archive(), + "pin" if self.focus == Focus::Conversation => return self.begin_message_pin(false), + "pin" | "pin_up" | "pin_down" => return self.change_chat_pin(run), + "pins" => return self.open_pinned_messages(), + "search" => return self.open_search(), + "chat_color" => return self.begin_color_picker(false), + "folder_color" => return self.begin_color_picker(true), + "folder_next" | "folder_previous" => { + let current = self + .folders + .iter() + .position(|folder| folder.id == self.folder_id) + .unwrap_or(0); + let total = self.folders.len(); + if total > 0 { + let index = if run == "folder_next" { + (current + 1) % total + } else { + (current + total - 1) % total + }; + self.folder_id = self.folders[index].id; + self.selected_chat = 0; + self.focus = Focus::Chats; + self.narrow_conversation = false; + self.mode = Mode::Navigate; + } + return Vec::new(); + } + "chat_info" => { + let chat = if self.focus == Focus::Chats { + self.selected_chat_entry() + } else { + self.chats + .iter() + .find(|chat| Some(chat.id) == self.active_chat_id) + }; + self.status_message = chat.map(|chat| { + format!( + "{} · chat ID {} · folder ID {}", + chat.title, chat.id, self.folder_id + ) + }); + return Vec::new(); + } + "help" | "settings" | "accounts" if self.screen == Screen::Main => { + if (run == "help" && self.mode == Mode::Help) + || (run == "settings" && self.mode == Mode::Settings) + || (run == "accounts" && self.mode == Mode::Accounts) + { + return self.handle_main(KeyAction::Escape); + } + let character = match run { + "help" => '?', + "settings" => 's', + _ => 'a', + }; + return self.handle_navigation(KeyAction::Character(character)); + } + "message_up" => return self.move_messages_up(count), + "message_down" => { + let messages = self.active_messages(); + if messages.is_empty() { + return Vec::new(); + } + let index = self + .selected_message + .and_then(|id| messages.iter().position(|message| message.id == id)) + .unwrap_or(messages.len() - 1); + let target = index.saturating_add(count).min(messages.len() - 1); + let id = messages[target].id; + self.select_message_id(id, true); + return Vec::new(); + } + "up" if self.mode == Mode::Navigate => return self.move_up(count), + "down" if self.mode == Mode::Navigate => return self.move_down(count), + "page_up" => return self.move_up(PAGE_STEP.saturating_mul(count)), + "page_down" => return self.move_down(PAGE_STEP.saturating_mul(count)), + "latest" if self.browsing_older && self.focus == Focus::Conversation => { + if let Some(id) = self.active_chat_id + && let Some(index) = self.chats.iter().position(|chat| chat.id == id) + { + self.filter.clear(); + self.folder_id = 0; + self.selected_chat = index; + return self.open_selected_chat(); + } + return Vec::new(); + } + "latest" => return self.move_to_end(), + "oldest" => return self.move_to_start(), + "refresh" => { + let mut commands = self.request_dialog_refresh(); + commands.push(TelegramCommand::RefreshFolders); + return commands; + } + "filter" => { + self.focus = Focus::Chats; + self.mode = Mode::Filter; + return Vec::new(); + } + "compose" => return self.compose_or_reply(), + "preview" => return self.preview_selected_media(), + "send" => { + return self + .active_chat_id + .map_or_else(Vec::new, |id| self.send_draft(id)); + } + "reply" => return self.start_replying_to_selected(), + "reply_target" => return self.navigate_to_selected_reply(), + "open_link" => return self.activate_selected_link(), + "next_action" => return self.select_actionable_message(true), + "previous_action" => return self.select_actionable_message(false), + "reveal" => return self.reveal_selected_attachment(), + "noop" => return Vec::new(), + _ => {} + } + let action = match run { + "quit" => KeyAction::Quit, + "help" => KeyAction::Character('?'), + "settings" => KeyAction::Character('s'), + "accounts" => KeyAction::Character('a'), + "next_account" => KeyAction::NextAccount, + "add_account" => KeyAction::AddAccount, + "open" => KeyAction::Enter, + "cancel" => KeyAction::Escape, + "focus" => KeyAction::Tab, + "newline" => KeyAction::Newline, + "redraw" => KeyAction::Redraw, + "up" => KeyAction::Up, + "down" => KeyAction::Down, + "home" => KeyAction::Home, + "end" => KeyAction::End, + "left" => KeyAction::Left, + "right" => KeyAction::Right, + "backspace" => KeyAction::Backspace, + "delete" => KeyAction::Delete, + "clear" => KeyAction::Clear, + "delete_word" => KeyAction::DeleteWord, + _ => return Vec::new(), + }; + self.handle_action(action) + } + + fn move_messages_up(&mut self, count: usize) -> Vec { + let messages = self.active_messages(); + if messages.is_empty() { + return Vec::new(); + } + let index = self + .selected_message + .and_then(|id| messages.iter().position(|message| message.id == id)) + .unwrap_or(messages.len() - 1); + let target = index.saturating_sub(count); + let id = messages[target].id; + self.select_message_id(id, true); + if count <= index { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.active_history_request = None; + self.history_changes.clear(); + self.older_motion = Some((chat_id, request_id, count - index)); + self.status_message = Some("Loading earlier messages…".to_owned()); + vec![TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id: id, + }] + } + + fn finish_older( + &mut self, + chat_id: ChatId, + request_id: u64, + before_id: i32, + mut messages: Vec, + ) -> Vec { + let Some((chat, request, remaining)) = self.older_motion else { + return Vec::new(); + }; + if chat != chat_id || request != request_id || self.active_chat_id != Some(chat_id) { + return Vec::new(); + } + self.older_motion = None; + messages.retain(|message| { + message.id < before_id + && !self + .history_changes + .get(&message.id) + .is_some_and(Option::is_none) + }); + for message in &mut messages { + if let Some(Some(current)) = self.history_changes.get(&message.id) { + *message = current.clone(); + } + sanitize_message(message); + } + if messages.is_empty() { + self.status_message = Some("Start of available history".to_owned()); + return Vec::new(); + } + let current = self.messages.remove(&chat_id).unwrap_or_default(); + let pending = current + .iter() + .filter(|message| message.id < 0) + .cloned() + .collect::>(); + let mut combined = messages; + combined.extend(current.into_iter().filter(|message| message.id > 0)); + combined.sort_by_key(|message| (message.timestamp, message.id)); + combined.dedup_by_key(|message| message.id); + combined.truncate(MAX_MESSAGES_PER_CHAT.saturating_sub(pending.len())); + combined.extend(pending); + self.messages.insert(chat_id, combined); + self.browsing_older = true; + self.loading_history = false; + self.selected_message = Some(before_id); + self.move_messages_up(remaining) } + #[allow(clippy::too_many_lines)] pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec { + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + self.older_motion = None; + } if !matches!(self.screen, Screen::Main) { return Vec::new(); } - if self.mode == Mode::Help { + if matches!( + self.mode, + Mode::Help + | Mode::Search + | Mode::Colors + | Mode::PinnedMessages + | Mode::PinPrompt + | Mode::Preview + ) { return Vec::new(); } if self.mode == Mode::Settings { @@ -509,13 +882,17 @@ impl App { MouseEventKind::ScrollUp if pointer_in_region(self.chat_pane_region, mouse.column, mouse.row) => { - self.focus = Focus::Chats; + if self.mode != Mode::Compose { + self.focus = Focus::Chats; + } self.move_chat_up(3) } MouseEventKind::ScrollDown if pointer_in_region(self.chat_pane_region, mouse.column, mouse.row) => { - self.focus = Focus::Chats; + if self.mode != Mode::Compose { + self.focus = Focus::Chats; + } self.move_chat_down(3) } MouseEventKind::ScrollUp @@ -534,11 +911,15 @@ impl App { } MouseEventKind::Down(MouseButton::Right) => self.handle_reply_click(mouse), MouseEventKind::Down(MouseButton::Left) => { + if pointer_in_region(self.composer_region, mouse.column, mouse.row) { + return self.start_composing(); + } if let Some(selection) = pointer_row_hit(&self.chat_hit_regions, mouse.column, mouse.row) { self.selected_chat = selection; self.focus = Focus::Chats; + self.mode = Mode::Navigate; self.narrow_conversation = false; self.selected_message = None; return self.open_selected_chat(); @@ -548,17 +929,16 @@ impl App { { self.focus = Focus::Conversation; self.narrow_conversation = true; - self.selected_message = Some(message_id); - if let Some(action_index) = action_index { - self.selected_action = action_index; - return self.activate_selected_message(); - } + self.mode = Mode::Navigate; self.select_message_id(message_id, false); + self.selected_action = action_index.unwrap_or(0); return Vec::new(); } if pointer_in_region(self.conversation_pane_region, mouse.column, mouse.row) { self.focus = Focus::Conversation; self.narrow_conversation = true; + self.mode = Mode::Navigate; + self.selected_message = None; } Vec::new() } @@ -599,7 +979,184 @@ impl App { #[allow(clippy::too_many_lines)] pub fn handle_network(&mut self, event: NetworkEvent) -> Vec { + self.track_snapshot_changes(&event); + if let NetworkEvent::MessageUpdated(message) = &event { + self.update_pin_preview(message); + } match event { + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page, + } => self.finish_pinned_messages(chat_id, request_id, page), + NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + error, + } => { + self.fail_pinned_messages(chat_id, request_id, &error); + Vec::new() + } + NetworkEvent::PinnedContext { + chat_id, + message_id, + request_id, + messages, + } => { + self.finish_pinned_context(chat_id, message_id, request_id, messages); + Vec::new() + } + NetworkEvent::MessagePinsChanged { + chat_id, + message_ids, + pinned, + } => self.pin_messages_changed(chat_id, Some(&message_ids), pinned), + NetworkEvent::MessagePinsCleared { chat_id } => { + self.pin_messages_changed(chat_id, None, false) + } + NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error, + } => self.finish_message_pin(chat_id, request_id, error), + NetworkEvent::ArchiveChanged { chat_id, archived } => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.membership.archived = archived; + } + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::DialogPins(pins) => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + self.pins.dialogs = pins; + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::DialogPinFinished { request_id, error } => { + self.finish_dialog_pin(request_id, error); + Vec::new() + } + NetworkEvent::AccountIdentity { user_id } => { + self.account_user_id = Some(user_id); + Vec::new() + } + NetworkEvent::SearchResults { request_id, page } => { + self.finish_search(request_id, Ok(page)); + Vec::new() + } + NetworkEvent::SearchFailed { request_id, error } => { + self.finish_search(request_id, Err(error)); + Vec::new() + } + NetworkEvent::CachedContext { + request_id, + chat_id, + message_id, + messages, + } => { + self.open_search_context(request_id, chat_id, message_id, messages); + Vec::new() + } + NetworkEvent::OlderHistory { + chat_id, + request_id, + before_id, + messages, + } => self.finish_older(chat_id, request_id, before_id, messages), + NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + error, + } => { + if self + .older_motion + .is_some_and(|(chat, request, _)| chat == chat_id && request == request_id) + { + self.older_motion = None; + self.status_message = Some(error); + } + Vec::new() + } + NetworkEvent::Folders(mut folders) => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + if !folders.iter().any(|folder| folder.id == 1) { + folders.push(crate::folders::Folder::archive()); + } + self.folders = folders; + if !self + .folders + .iter() + .any(|folder| folder.id == self.folder_id) + { + self.folder_id = 0; + } + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::UnreadChanged { chat_id, unread } => { + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.unread = unread.max(u32::from(chat.membership.unread_mark)); + } + self.clamp_chat_selection(); + Vec::new() + } + NetworkEvent::CachedSnapshot { user_name, chats } => { + self.user_name = user_name; + self.screen = Screen::Main; + self.replace_dialogs(chats); + self.status_message = Some("Cached conversations · connecting…".to_owned()); + Vec::new() + } + NetworkEvent::CachedHistory { + chat_id, + request_id, + messages, + } => { + if self.active_history_request == Some((chat_id, request_id)) { + self.merge_history(chat_id, messages, self.history_target_message); + } + Vec::new() + } + NetworkEvent::HistoryLoading { .. } + | NetworkEvent::PinnedMessagesLoading { .. } + | NetworkEvent::SyncCheckpoint(_) + | NetworkEvent::AttachmentDownloadStarted { .. } + | NetworkEvent::CacheMessage(_) => Vec::new(), + NetworkEvent::CacheAccountReset { .. } => { + self.reset_for_account_switch(self.active_account()); + Vec::new() + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.downloading_attachments + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.reveal_after_download + .retain(|(id, _)| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.downloaded_attachments + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + if let Some(chat_id) = chat_id { + self.messages.remove(&chat_id); + } else { + self.messages.clear(); + } + let mut commands = self.invalidate_message_pins(chat_id); + commands.extend(self.request_dialog_refresh()); + if let Some(active) = self.active_chat_id + && chat_id.is_none_or(|id| id == active) + { + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.active_history_request = Some((active, request_id)); + self.history_changes.clear(); + self.history_read_max = 0; + self.loading_history = true; + commands.push(TelegramCommand::LoadHistory { + chat_id: active, + request_id, + }); + } + commands + } NetworkEvent::Auth(prompt) => { if self.auth_restart_pending && !matches!(&prompt, AuthPrompt::Phone) { return Vec::new(); @@ -631,6 +1188,11 @@ impl App { self.status_message = None; Vec::new() } + NetworkEvent::DialogsLoading => { + self.refresh_dialogs_pending = true; + self.changed_dialogs.clear(); + Vec::new() + } NetworkEvent::Dialogs(chats) => { self.refresh_dialogs_pending = false; self.replace_dialogs(chats); @@ -795,19 +1357,27 @@ impl App { message_ids, } => { self.remove_deleted_messages(channel_id, &message_ids); - Vec::new() + self.pinned_messages_deleted(channel_id, &message_ids) } NetworkEvent::AttachmentDownloaded { + request_id, chat_id, message_id, path, } => { + if self.downloading_attachments.get(&(chat_id, message_id)) != Some(&request_id) { + return Vec::new(); + } self.downloading_attachments.remove(&(chat_id, message_id)); self.downloaded_attachments .insert((chat_id, message_id), path.clone()); + if self.reveal_after_download.remove(&(chat_id, message_id)) { + self.reveal_download(&path); + return Vec::new(); + } let location = sanitize_terminal_line(&path.display().to_string()); self.status_message = Some(match self.settings.download_behavior { - DownloadBehavior::TempOnly => format!("Downloaded to {location}"), + DownloadBehavior::CacheOnly => format!("Downloaded to {location}"), DownloadBehavior::RevealOnActivation => { format!("Downloaded to {location} · activate again to reveal") } @@ -815,11 +1385,16 @@ impl App { Vec::new() } NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error, } => { + if self.downloading_attachments.get(&(chat_id, message_id)) != Some(&request_id) { + return Vec::new(); + } self.downloading_attachments.remove(&(chat_id, message_id)); + self.reveal_after_download.remove(&(chat_id, message_id)); self.status_message = Some(format!( "Download failed: {}", sanitize_terminal_line(&error) @@ -951,16 +1526,31 @@ impl App { #[must_use] pub fn filtered_chat_indices(&self) -> Vec { let query = self.filter.value().to_lowercase(); - self.chats + let folder = self + .folders + .iter() + .find(|folder| folder.id == self.folder_id); + let now = chrono::Utc::now().timestamp(); + let mut indices: Vec<_> = self + .chats .iter() .enumerate() .filter_map(|(index, chat)| { - (query.is_empty() - || chat.title.to_lowercase().contains(&query) - || chat.last_message.to_lowercase().contains(&query)) + (folder.is_none_or(|folder| folder.contains(chat, now)) + && (query.is_empty() + || chat.title.to_lowercase().contains(&query) + || chat.last_message.to_lowercase().contains(&query))) .then_some(index) }) - .collect() + .collect(); + indices.sort_by_key(|&index| { + ( + self.chat_pin_position(self.chats[index].id) + .unwrap_or(usize::MAX), + std::cmp::Reverse(self.chats[index].last_activity), + ) + }); + indices } #[must_use] @@ -1015,7 +1605,7 @@ impl App { AttachmentState::Downloaded } else if self .downloading_attachments - .contains(&(chat_id, message_id)) + .contains_key(&(chat_id, message_id)) { AttachmentState::Downloading } else { @@ -1074,6 +1664,10 @@ impl App { self.chat_pane_region = Some(region); } + pub const fn set_composer_region(&mut self, region: (u16, u16, u16, u16)) { + self.composer_region = Some(region); + } + pub const fn set_conversation_pane_region(&mut self, region: (u16, u16, u16, u16)) { self.conversation_pane_region = Some(region); } @@ -1092,10 +1686,14 @@ impl App { #[must_use] pub fn needs_animation(&self) -> bool { + if self.keymap.pending() { + return true; + } match self.screen { Screen::Connecting | Screen::Auth(AuthPhase::Qr { .. }) => true, Screen::Main => { self.loading_history + || self.search.loading || self.media_previews.values().any(|preview| preview.loading) || matches!( self.connection, @@ -1140,6 +1738,10 @@ impl App { .insert_str(&normalized); } } + Screen::Main if self.mode == Mode::Search && self.search.editing => { + self.search.query.insert_str(&normalized.replace('\n', "")); + return self.search_edited(); + } Screen::Main if self.mode == Mode::Filter => { self.filter .insert_str(&normalized.split_whitespace().collect::>().join(" ")); @@ -1252,7 +1854,17 @@ impl App { match self.mode { Mode::Navigate => self.handle_navigation(action), Mode::Compose => self.handle_compose(action), + Mode::Preview => { + if action == KeyAction::Escape { + self.mode = Mode::Navigate; + self.force_redraw = true; + } + Vec::new() + } Mode::Filter => self.handle_filter(action), + Mode::Search => self.edit_search(action), + Mode::PinnedMessages | Mode::PinPrompt => Vec::new(), + Mode::Colors => self.handle_colors(action), Mode::Help => self.handle_help(action), Mode::Settings => self.handle_settings(action), Mode::Accounts => self.handle_accounts(action), @@ -1291,9 +1903,7 @@ impl App { self.insert_active('/'); Vec::new() } - KeyAction::Character('i') if self.focus == Focus::Conversation => { - self.start_composing() - } + KeyAction::Character('i') => self.compose_or_reply(), KeyAction::Tab | KeyAction::BackTab => { self.toggle_focus(); if self.focus == Focus::Conversation && self.message_scroll == 0 { @@ -1302,6 +1912,10 @@ impl App { Vec::new() } } + KeyAction::Escape if self.selected_message.is_some() => { + self.selected_message = None; + Vec::new() + } KeyAction::Escape | KeyAction::Left => { self.narrow_conversation = false; self.focus = Focus::Chats; @@ -1325,10 +1939,10 @@ impl App { } } KeyAction::Character('o') if self.focus == Focus::Conversation => { - self.select_actionable_message(true) + self.preview_selected_media() } KeyAction::Character('O') if self.focus == Focus::Conversation => { - self.select_actionable_message(false) + self.reveal_selected_attachment() } KeyAction::Character('l') if self.focus == Focus::Conversation => { self.activate_selected_link() @@ -1468,6 +2082,20 @@ impl App { } fn handle_help(&mut self, action: KeyAction) -> Vec { + match action { + KeyAction::Up => { + self.help_scroll = self.help_scroll.saturating_sub(1); + return Vec::new(); + } + KeyAction::Down => { + self.help_scroll = self + .help_scroll + .saturating_add(1) + .min(self.keymap.help().len().saturating_sub(1)); + return Vec::new(); + } + _ => {} + } match action { KeyAction::Character('q') => self.quit(), KeyAction::Escape | KeyAction::Character('?') => { @@ -1574,7 +2202,11 @@ impl App { fn switch_to_next_account(&mut self) -> Vec { if self.settings.account_count < 2 { - self.status_message = Some("Only one account · press F3 to add another".to_owned()); + self.status_message = Some(format!( + "Only one account · {} to add another", + self.keymap + .hint(crate::keymap::Context::Global, "add_account") + )); return Vec::new(); } let account = if self.settings.active_account == self.settings.account_count { @@ -1631,7 +2263,14 @@ impl App { let available_update = self.available_update.clone(); let terminal_focused = self.terminal_focused; let qr_render_mode = self.qr_render_mode; + let appearance = self.appearance.clone(); + let navigation = self.navigation.clone(); + let mut keymap = self.keymap.clone(); + keymap.reset(); *self = Self { + appearance, + navigation, + keymap, settings, settings_path, available_update, @@ -1697,16 +2336,6 @@ impl App { self.narrow_conversation = self.focus == Focus::Conversation; } - fn start_composing(&mut self) -> Vec { - if let Some(chat_id) = self.active_chat_id { - self.drafts.entry(chat_id).or_default(); - self.mode = Mode::Compose; - self.focus = Focus::Conversation; - self.selected_message = None; - } - Vec::new() - } - fn start_plain_composing(&mut self) -> Vec { if let Some(chat_id) = self.active_chat_id { self.reply_targets.remove(&chat_id); @@ -1738,6 +2367,7 @@ impl App { let local_id = self.next_pending_id; self.next_pending_id = self.next_pending_id.checked_sub(1).unwrap_or(-1); let pending = Message { + pinned: false, id: local_id, chat_id, sender: "You".to_owned(), @@ -1782,6 +2412,7 @@ impl App { }; let item_reply = if index == 0 { reply_to.take() } else { None }; let pending = Message { + pinned: false, id: local_id, chat_id, sender: "You".to_owned(), @@ -1846,24 +2477,28 @@ impl App { self.viewport_anchor_row = 0; self.message_scroll = 1; } - self.status_message = Some(format!("Selected #{message_id} · R reply")); + self.status_message = None; } fn start_replying_to_selected(&mut self) -> Vec { + if self.mode == Mode::Preview && self.selected_message.is_none() { + self.status_message = Some(format!( + "Previewed message is no longer available · {} close", + self.keymap.hint(Context::Preview, "cancel") + )); + return Vec::new(); + } let Some(chat_id) = self.active_chat_id else { return Vec::new(); }; let target = self - .selected_message - .and_then(|message_id| { - self.messages - .get(&chat_id) - .and_then(|messages| messages.iter().find(|message| message.id == message_id)) - }) - .or_else(|| { - self.messages - .get(&chat_id) - .and_then(|messages| messages.iter().rev().find(|message| message.id > 0)) + .messages + .get(&chat_id) + .and_then(|messages| { + self.selected_message.map_or_else( + || messages.iter().rev().find(|message| message.id > 0), + |id| messages.iter().find(|message| message.id == id), + ) }) .cloned(); let Some(target) = target else { @@ -2032,7 +2667,11 @@ impl App { if message.id > 0 && let Some(attachment) = message.attachment.as_ref().filter(|a| a.supports_preview()) { - // A click retries a failed inline download; it never opens a modal. + self.mode = Mode::Preview; + self.focus = Focus::Conversation; + self.status_message = None; + // Explicit activation can retry a failed inline download while the + // same renderer expands the preview into the available viewport. if self .media_previews .get(&(chat_id, message_id)) @@ -2051,7 +2690,7 @@ impl App { { if path.is_file() { let location = sanitize_terminal_line(&path.display().to_string()); - if self.settings.download_behavior == DownloadBehavior::TempOnly { + if self.settings.download_behavior == DownloadBehavior::CacheOnly { self.status_message = Some(format!( "Downloaded to {location} · reveal is disabled in settings" )); @@ -2067,15 +2706,14 @@ impl App { } self.downloaded_attachments.remove(&(chat_id, message_id)); } - if !self.downloading_attachments.insert((chat_id, message_id)) { - self.status_message = Some("Attachment is downloading…".to_owned()); - return Vec::new(); - } - self.status_message = Some("Downloading attachment…".to_owned()); - return vec![TelegramCommand::DownloadAttachment { + return self.queue_download( chat_id, message_id, - }]; + message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id), + ); } Vec::new() @@ -2160,11 +2798,17 @@ impl App { } pub fn media_preview_failed(&mut self, chat_id: ChatId, message_id: i32, error: &str) { + let context = if self.mode == Mode::Preview { + Context::Preview + } else { + Context::Conversation + }; if let Some(preview) = self.media_previews.get_mut(&(chat_id, message_id)) { preview.path = None; preview.loading = false; preview.status = format!( - "Preview unavailable · click to retry: {}", + "Preview unavailable · {} retry: {}", + self.keymap.hint(context, "preview"), sanitize_terminal_line(error) ); } @@ -2173,6 +2817,19 @@ impl App { fn remove_deleted_messages(&mut self, channel_id: Option, message_ids: &[i32]) { let affected_chat = |id: ChatId| channel_id.map_or(id > -1_000_000_000_000, |channel| channel == id); + for chat in &mut self.chats { + if affected_chat(chat.id) + && chat + .last_message_id + .is_some_and(|id| message_ids.contains(&id)) + { + chat.last_message.clear(); + chat.last_message_id = None; + if self.refresh_dialogs_pending { + self.changed_dialogs.insert(chat.id); + } + } + } self.media_previews.retain(|&(chat_id, message_id), _| { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); @@ -2186,9 +2843,12 @@ impl App { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); self.downloading_attachments - .retain(|&(chat_id, message_id)| { + .retain(|&(chat_id, message_id), _| { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); + self.reveal_after_download.retain(|&(chat_id, message_id)| { + !affected_chat(chat_id) || !message_ids.contains(&message_id) + }); if self.active_chat_id.is_some_and(affected_chat) && self .selected_message @@ -2209,7 +2869,11 @@ impl App { }) .cloned() else { - self.status_message = Some("Select a link with o first".to_owned()); + self.status_message = Some(format!( + "Select a link with {} first", + self.keymap + .hint(crate::keymap::Context::Conversation, "next_action") + )); return Vec::new(); }; let links = &message.links; @@ -2261,7 +2925,11 @@ impl App { }) .cloned() else { - self.status_message = Some("Select a reply with o first".to_owned()); + self.status_message = Some(format!( + "Select a reply with {} first", + self.keymap + .hint(crate::keymap::Context::Conversation, "next_action") + )); return Vec::new(); }; let Some(reply) = message.reply_to else { @@ -2336,6 +3004,7 @@ impl App { chat.title = sanitize_terminal_line(&chat.title); chat.last_message = sanitize_terminal_text(&chat.last_message); if let Some(index) = known_chat { + chat.membership.clone_from(&self.chats[index].membership); self.chats[index] = chat; } else { self.chats.push(chat); @@ -2343,8 +3012,9 @@ impl App { self.linked_chat_ids.insert(chat_id); } if let Some(index) = known_chat { + self.folder_id = i32::from(self.chats[index].membership.archived); self.filter.clear(); - self.selected_chat = index; + self.preserve_chat_selection(Some(chat_id)); } // Pin the destination before insertion so the bounded global cache // cannot evict an older exact link/reply target during navigation. @@ -2369,11 +3039,16 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.older_motion = None; + self.browsing_older = false; + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = selected_message; if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.unread = 0; } self.status_message = None; + self.remember_chat(chat_id); let mut commands = vec![TelegramCommand::LoadHistory { chat_id, request_id, @@ -2387,6 +3062,7 @@ impl App { return Vec::new(); }; self.active_chat_id = Some(chat_id); + self.remember_chat(chat_id); self.selected_message = None; self.focus = Focus::Conversation; self.narrow_conversation = true; @@ -2398,6 +3074,10 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.older_motion = None; + self.browsing_older = false; + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = None; if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { chat.unread = 0; @@ -2500,6 +3180,50 @@ impl App { self.selected_chat = self.selected_chat.min(length.saturating_sub(1)); } + /// Overlay events received after a snapshot request. A slower history RPC + /// must not resurrect deleted messages or undo a live edit/read receipt. + fn track_snapshot_changes(&mut self, event: &NetworkEvent) { + let history_chat = self + .active_history_request + .map(|(chat_id, _)| chat_id) + .or_else(|| self.older_motion.map(|(chat_id, _, _)| chat_id)); + let history_chat = self.pin_context_chat().or(history_chat); + match event { + NetworkEvent::NewMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } => { + if history_chat == Some(message.chat_id) { + self.history_changes + .insert(message.id, Some(message.clone())); + } + if self.refresh_dialogs_pending { + self.changed_dialogs.insert(message.chat_id); + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + if let Some(chat_id) = history_chat + && channel_id.map_or(chat_id > -1_000_000_000_000, |id| id == chat_id) + { + for id in message_ids { + self.history_changes.insert(*id, None); + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } if history_chat == Some(*chat_id) => { + self.history_read_max = self.history_read_max.max(*max_id); + } + NetworkEvent::ReadMarked { chat_id } | NetworkEvent::UnreadChanged { chat_id, .. } + if self.refresh_dialogs_pending => + { + self.changed_dialogs.insert(*chat_id); + } + _ => {} + } + } + fn replace_dialogs(&mut self, mut chats: Vec) { let selected_id = self.selected_chat_entry().map(|chat| chat.id); let transient_linked = self @@ -2512,6 +3236,38 @@ impl App { .cloned() .collect::>(); chats.extend(transient_linked); + for chat in &mut chats { + if self.changed_dialogs.contains(&chat.id) { + if let Some(current) = self.chats.iter().find(|current| current.id == chat.id) { + chat.last_message.clone_from(¤t.last_message); + chat.last_activity = current.last_activity; + chat.last_message_id = current.last_message_id; + chat.unread = current.unread; + } else if let Some(message) = self + .messages + .get(&chat.id) + .and_then(|messages| messages.last()) + && chat + .last_activity + .is_none_or(|timestamp| message.timestamp >= timestamp) + { + chat.last_message.clone_from(&message.text); + chat.last_message_id = Some(message.id); + chat.last_activity = Some(message.timestamp); + } + } + } + for current in &self.chats { + if self.changed_dialogs.contains(¤t.id) + && !chats.iter().any(|chat| chat.id == current.id) + { + chats.push(current.clone()); + } + } + if !self.changed_dialogs.is_empty() { + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + } + self.changed_dialogs.clear(); self.linked_chat_ids .retain(|chat_id| chats.iter().any(|chat| chat.id == *chat_id)); for chat in &mut chats { @@ -2602,6 +3358,9 @@ impl App { { let chat = &mut self.chats[chat_index]; chat.last_message = text; + if message_id > 0 { + chat.last_message_id = Some(message_id); + } chat.last_activity = Some(timestamp); if genuinely_new && count_as_new @@ -2673,12 +3432,21 @@ impl App { } match result { Ok(messages) => { + let mut messages = messages; + messages.retain(|message| !self.history_changes.contains_key(&message.id)); + messages.extend(self.history_changes.values().flatten().cloned()); + for message in &mut messages { + if message.outgoing && message.id > 0 && message.id <= self.history_read_max { + message.delivery = Delivery::Read; + } + } self.merge_history(chat_id, messages, self.history_target_message); self.status_message = None; } Err(error) => self.status_message = Some(sanitize_terminal_line(&error)), } self.active_history_request = None; + self.history_changes.clear(); self.history_target_message = None; self.loading_history = false; Vec::new() @@ -2879,20 +3647,36 @@ impl App { sanitize_message(&mut message); let chat_id = message.chat_id; let message_id = message.id; - self.media_previews.remove(&(chat_id, message_id)); - // Telegram can replace media while retaining its name, MIME type, and size. - self.downloaded_attachments.remove(&(chat_id, message_id)); - let attachment_changed = self.messages.get(&chat_id).is_some_and(|messages| { - messages - .iter() - .find(|current| current.id == message_id) - .is_some_and(|current| current.attachment != message.attachment) - }); - if attachment_changed { + let media_id = message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id); + let same_media = media_id.is_some() + && self + .messages + .get(&chat_id) + .and_then(|messages| messages.iter().find(|current| current.id == message_id)) + .and_then(|current| current.attachment.as_ref()) + .and_then(|attachment| attachment.source_id) + == media_id; + if !same_media { + self.media_previews.remove(&(chat_id, message_id)); self.downloaded_attachments.remove(&(chat_id, message_id)); - self.downloading_attachments.remove(&(chat_id, message_id)); + if self + .downloading_attachments + .remove(&(chat_id, message_id)) + .is_some() + { + self.status_message = Some(format!( + "Attachment changed · {} to try again", + self.keymap + .hint(crate::keymap::Context::Conversation, "reveal") + )); + } + self.reveal_after_download.remove(&(chat_id, message_id)); } let preview = message.text.clone(); + let timestamp = message.timestamp; let reply_to = message.reply_to.as_ref().map(|reply| reply.message_id); let reconciled_local_id = { let messages = self.messages.entry(chat_id).or_default(); @@ -2913,10 +3697,15 @@ impl App { } else { upsert_message(messages, message); } - let is_latest = messages - .last() - .is_some_and(|latest| latest.id == message_id); - if is_latest && let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) + && chat.last_message_id.map_or_else( + || { + chat.last_activity + .is_none_or(|activity| timestamp >= activity) + }, + |id| message_id == id, + ) + { chat.last_message = preview; } self.prune_message_cache(); @@ -3045,11 +3834,13 @@ impl App { .is_some_and(|messages| messages.iter().any(|message| message.id == message_id)) }); self.downloading_attachments - .retain(|&(chat_id, message_id)| { + .retain(|&(chat_id, message_id), _| { self.messages .get(&chat_id) .is_some_and(|messages| messages.iter().any(|message| message.id == message_id)) }); + self.reveal_after_download + .retain(|key| self.downloading_attachments.contains_key(key)); if self.messages.len() <= MAX_CACHED_CHATS { return; } @@ -3305,6 +4096,7 @@ fn attachment_from_path(path: &Path) -> Attachment { _ => (AttachmentKind::File, None), }; Attachment { + source_id: None, kind, file_name: path .file_name() @@ -3323,7 +4115,11 @@ fn reveal_path(path: &Path) -> std::io::Result<()> { "downloaded file no longer exists", )); } - reveal_path_platform(path).map(|_| ()) + let mut child = reveal_path_platform(path)?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) } #[cfg(target_os = "macos")] @@ -3489,7 +4285,7 @@ mod tests { use std::fs; use chrono::{TimeZone, Utc}; - use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + use yazi_term::event::{KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind}; use super::{ App, AttachmentState, AuthPhase, Focus, MAX_CACHED_CHATS, MAX_MESSAGES_PER_CHAT, Mode, @@ -3507,10 +4303,12 @@ mod tests { fn chat(id: i64, title: &str) -> Chat { Chat { + membership: crate::folders::ChatMembership::default(), id, title: title.to_owned(), kind: ChatKind::Direct, unread: 3, + last_message_id: None, last_message: format!("from {title}"), last_activity: None, } @@ -3518,6 +4316,7 @@ mod tests { fn message(id: i32, chat_id: i64, text: &str, outgoing: bool) -> Message { Message { + pinned: false, id, chat_id, sender: if outgoing { "Me" } else { "Them" }.to_owned(), @@ -3556,6 +4355,95 @@ mod tests { }); } + #[test] + fn counted_message_motion_continues_across_a_history_page() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: (81..=160) + .map(|id| message(id, 1, "wrapped\nmessage", false)) + .collect(), + }); + app.keymap = crate::keymap::Keymap::parse( + "return {keymap={{context='conversation',on={''},run='message_up',count=100}}}", + ) + .unwrap(); + let commands = app.handle_key(&KeyEvent::new( + yazi_term::event::KeyCode::Char('u'), + Modifiers::CONTROL, + )); + let TelegramCommand::LoadOlder { + request_id, + before_id, + .. + } = commands[0] + else { + panic!("load older page"); + }; + assert_eq!(before_id, 81); + app.handle_network(NetworkEvent::OlderHistory { + chat_id: 1, + request_id, + before_id, + messages: (1..=80).map(|id| message(id, 1, "older", false)).collect(), + }); + assert_eq!(app.selected_message, Some(60)); + assert!( + app.run_binding("latest", 1) + .iter() + .any(|command| matches!(command, TelegramCommand::LoadHistory { .. })) + ); + } + + #[test] + fn cached_conversations_are_usable_before_network_authentication() { + let mut app = App::new(); + app.handle_network(NetworkEvent::CachedSnapshot { + user_name: Some("Ada".to_owned()), + chats: vec![chat(1, "Cached")], + }); + assert_eq!(app.screen, Screen::Main); + assert_eq!(app.connection, crate::event::ConnectionStatus::Connecting); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::CachedHistory { + chat_id: 1, + request_id: 1, + messages: vec![message(1, 1, "available offline", false)], + }); + assert_eq!(app.active_messages()[0].text, "available offline"); + assert!( + app.loading_history, + "cached content remains visible during reconciliation" + ); + } + + #[test] + fn global_overlay_toggles_restore_the_composer_and_draft() { + use yazi_term::event::KeyCode; + let mut app = ready_app(); + open_first(&mut app); + app.start_composing(); + app.handle_action(KeyAction::Character('x')); + app.keymap = crate::keymap::Keymap::parse( + r"return {keymap={ + {context='global',on={''},run='help'}, + {context='global',on={''},run='settings'}, + {context='global',on={''},run='accounts'}, + }}", + ) + .unwrap(); + for (key, mode) in [(4, Mode::Help), (5, Mode::Settings), (6, Mode::Accounts)] { + let event = KeyEvent::new(KeyCode::Fn(key), Modifiers::empty()); + app.handle_key(&event); + assert_eq!(app.mode, mode); + app.handle_key(&event); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.active_draft().unwrap().value(), "x"); + } + } + #[test] fn password_is_masked_and_submitted_without_trimming() { let mut app = App::new(); @@ -3616,13 +4504,14 @@ mod tests { #[test] fn tab_starts_qr_login_and_escape_drops_the_transient_token() { + use yazi_term::event::{KeyCode, KeyEvent}; + let tab = KeyEvent::new(KeyCode::Tab, Modifiers::empty()); + let back_tab = KeyEvent::new(KeyCode::Tab, Modifiers::SHIFT); + let escape = KeyEvent::new(KeyCode::Escape, Modifiers::empty()); let mut app = App::new(); app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); app.handle_action(KeyAction::Character('+')); - assert_eq!( - app.handle_action(KeyAction::Tab), - vec![TelegramCommand::StartQrAuth] - ); + assert_eq!(app.handle_key(&tab), vec![TelegramCommand::StartQrAuth]); assert!(app.auth_input().is_empty()); assert_eq!(app.auth_progress_label(), Some("Preparing QR sign-in…")); @@ -3637,18 +4526,15 @@ mod tests { assert!(app.needs_animation()); assert!(!format!("{:?}", app.screen).contains(secret_url)); assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); - assert!(app.handle_action(KeyAction::Tab).is_empty()); + assert!(app.handle_key(&tab).is_empty()); assert_eq!(app.qr_render_mode(), QrRenderMode::Compatible); - assert!(app.handle_action(KeyAction::BackTab).is_empty()); + assert!(app.handle_key(&back_tab).is_empty()); assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); assert_eq!( app.auth_progress_label(), Some("Waiting for approval in Telegram…") ); - assert_eq!( - app.handle_action(KeyAction::Escape), - vec![TelegramCommand::RestartAuth] - ); + assert_eq!(app.handle_key(&escape), vec![TelegramCommand::RestartAuth]); assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); assert!(app.auth_is_submitting()); assert_eq!( @@ -4026,6 +4912,68 @@ mod tests { assert!(app.active_messages().iter().any(|message| message.id == -1)); } + #[test] + fn history_snapshot_cannot_undo_live_messages_edits_deletions_or_reads() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::MessageUpdated(message(1, 1, "edited", false))); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }); + app.handle_network(NetworkEvent::NewMessage(message( + 4, + 1, + "arrived during request", + false, + ))); + app.handle_network(NetworkEvent::MessagesRead { + chat_id: 1, + max_id: 3, + }); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: vec![ + message(1, 1, "old", false), + message(2, 1, "deleted", false), + message(3, 1, "sent", true), + ], + }); + let messages = app.active_messages(); + assert_eq!( + messages + .iter() + .map(|message| message.id) + .collect::>(), + vec![1, 3, 4] + ); + assert_eq!(messages[0].text, "edited"); + assert_eq!(messages[1].delivery, Delivery::Read); + assert_eq!(messages[2].text, "arrived during request"); + } + + #[test] + fn dialog_snapshot_cannot_undo_activity_or_reads_during_refresh() { + let mut app = ready_app(); + app.handle_network(NetworkEvent::DialogsLoading); + app.handle_network(NetworkEvent::NewMessage(message( + 25, + 2, + "live preview", + false, + ))); + let unread = app.chats.iter().find(|chat| chat.id == 2).unwrap().unread; + app.handle_network(NetworkEvent::Dialogs(vec![ + chat(1, "Renamed"), + chat(2, "Beta"), + ])); + assert_eq!(app.chats[0].id, 2); + assert_eq!(app.chats[0].last_message, "live preview"); + assert_eq!(app.chats[0].unread, unread); + assert_eq!(app.chats[1].title, "Renamed"); + } + #[test] fn histories_and_live_updates_keep_only_the_lightweight_history_window() { let mut app = ready_app(); @@ -4212,7 +5160,7 @@ mod tests { Settings { automatic_update_checks: false, release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, + download_behavior: DownloadBehavior::CacheOnly, show_message_ids: true, ..Settings::default() } @@ -4330,10 +5278,11 @@ mod tests { #[test] fn temp_only_downloads_never_reveal_remote_files() { let mut app = ready_app(); - app.settings.download_behavior = DownloadBehavior::TempOnly; + app.settings.download_behavior = DownloadBehavior::CacheOnly; open_first(&mut app); let mut attachment = message(21, 1, "", false); attachment.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::File, file_name: Some("remote.command".to_owned()), mime_type: None, @@ -4343,7 +5292,9 @@ mod tests { app.messages.get_mut(&1).unwrap().push(attachment); let path = std::env::temp_dir().join(format!("termgram-temp-only-{}", std::process::id())); fs::write(&path, b"x").expect("download fixture"); + app.downloading_attachments.insert((1, 21), 1); app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, chat_id: 1, message_id: 21, path: path.clone(), @@ -4543,6 +5494,7 @@ mod tests { open_first(&mut app); let mut photo = message(21, 1, "see https://t.me/rustlang/42", false); photo.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("photo.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -4576,7 +5528,7 @@ mod tests { }]; app.handle_network(NetworkEvent::NewMessage(actionable)); - app.handle_action(KeyAction::Character('o')); + app.run_binding("next_action", 1); assert_eq!(app.selected_message, Some(21)); assert_eq!(app.selected_action, 0); assert_eq!( @@ -4586,7 +5538,7 @@ mod tests { }] ); - app.handle_action(KeyAction::Character('o')); + app.run_binding("next_action", 1); assert_eq!(app.selected_action, 1); assert_eq!( app.handle_action(KeyAction::Enter), @@ -4604,10 +5556,12 @@ mod tests { open_first(&mut app); let linked = message(5, 99, "linked target", false); let linked_chat = Chat { + membership: crate::folders::ChatMembership::default(), id: 99, title: "Linked group".to_owned(), kind: ChatKind::Group, unread: 0, + last_message_id: None, last_message: "linked target".to_owned(), last_activity: None, }; @@ -4625,7 +5579,10 @@ mod tests { assert_eq!(app.viewport_anchor_message, Some(5)); assert_eq!(app.selected_message, Some(5)); app.handle_action(KeyAction::Character('i')); - assert_eq!(app.selected_message, None); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(5) + ); app.handle_network(NetworkEvent::History { chat_id: 99, @@ -4689,7 +5646,7 @@ mod tests { }) .collect(), ); - app.handle_action(KeyAction::Character('o')); + app.run_binding("next_action", 1); assert_eq!(app.selected_message, Some(2)); assert_eq!(app.viewport_anchor_message, Some(2)); assert!(app.message_scroll > 0); @@ -4716,7 +5673,7 @@ mod tests { sender: Some("Them".to_owned()), }); - assert!(app.handle_action(KeyAction::Character('o')).is_empty()); + assert!(app.run_binding("next_action", 1).is_empty()); assert_eq!(app.selected_message, Some(20)); assert!(app.handle_action(KeyAction::Character('r')).is_empty()); assert_eq!(app.selected_message, Some(5)); @@ -4734,7 +5691,7 @@ mod tests { chat_id: 1, sender: None, }); - app.handle_action(KeyAction::Character('o')); + app.run_binding("next_action", 1); assert_eq!( app.handle_action(KeyAction::Character('r')), vec![TelegramCommand::LoadMessage { @@ -4791,7 +5748,7 @@ mod tests { chat_id: 2, sender: Some("@beta".to_owned()), }); - app.handle_action(KeyAction::Character('o')); + app.run_binding("next_action", 1); let commands = app.handle_action(KeyAction::Character('r')); assert_eq!( commands, @@ -5024,6 +5981,7 @@ mod tests { #[test] fn optimistic_attachments_reconcile_by_identity_not_empty_caption() { let first = Message { + pinned: false, id: -1, chat_id: 1, sender: "You".to_owned(), @@ -5033,6 +5991,7 @@ mod tests { outgoing: true, delivery: Delivery::Pending, attachment: Some(Attachment { + source_id: None, kind: AttachmentKind::File, file_name: Some("first.txt".to_owned()), mime_type: Some("text/plain".to_owned()), @@ -5043,14 +6002,17 @@ mod tests { buttons: Vec::new(), }; let second = Message { + pinned: false, id: -2, attachment: Some(Attachment { + source_id: None, file_name: Some("second.txt".to_owned()), ..first.attachment.clone().unwrap() }), ..first.clone() }; let server = Message { + pinned: false, id: 42, attachment: second.attachment.clone(), delivery: Delivery::Sent, @@ -5070,6 +6032,7 @@ mod tests { local.timestamp = Utc::now(); local.delivery = Delivery::Pending; local.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("holiday.png".to_owned()), mime_type: Some("image/png".to_owned()), @@ -5096,6 +6059,7 @@ mod tests { let mut local = message(-1, 1, "", true); local.delivery = Delivery::Pending; local.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::File, file_name: Some("notes.txt".to_owned()), mime_type: Some("text/plain".to_owned()), @@ -5120,6 +6084,7 @@ mod tests { open_first(&mut app); let mut original = message(21, 1, "", false); original.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::File, file_name: Some("before.txt".to_owned()), mime_type: Some("text/plain".to_owned()), @@ -5127,7 +6092,9 @@ mod tests { fallback_emoji: None, }); app.handle_network(NetworkEvent::NewMessage(original.clone())); + app.downloading_attachments.insert((1, 21), 1); app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, chat_id: 1, message_id: 21, path: std::env::temp_dir().join("termgram-edited-fixture"), @@ -5146,6 +6113,7 @@ mod tests { for id in 21..=23 { let mut photo = message(id, 1, "caption", false); photo.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::Sticker, file_name: Some("sticker.tgs".to_owned()), mime_type: Some("application/x-tgsticker".to_owned()), @@ -5214,6 +6182,108 @@ mod tests { assert!(app.request_visible_media().is_empty()); } + #[test] + fn compose_restores_last_chat_by_account_across_restart() { + use yazi_term::event::KeyCode; + + let directory = + std::env::temp_dir().join(format!("termgram-navigation-{}", std::process::id())); + let settings_path = directory.join("settings.conf"); + let mut first = ready_app(); + first.settings_path = Some(settings_path.clone()); + first.account_user_id = Some(101); + first.selected_chat = 1; + first.open_selected_chat(); + + let mut restarted = ready_app(); + restarted.settings_path = Some(settings_path.clone()); + restarted.account_user_id = Some(101); + restarted.load_navigation().unwrap(); + let commands = restarted.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert!(matches!( + commands.first(), + Some(TelegramCommand::LoadHistory { chat_id: 2, .. }) + )); + assert_eq!(restarted.mode, Mode::Compose); + assert_eq!(restarted.active_chat_id, Some(2)); + + let mut other = ready_app(); + other.settings_path = Some(settings_path); + other.account_user_id = Some(102); + other.load_navigation().unwrap(); + other.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!(other.active_chat_id, Some(1)); + assert!(other.active_reply_target().is_none()); + let stored: serde_json::Value = + serde_json::from_str(&fs::read_to_string(directory.join("navigation.json")).unwrap()) + .unwrap(); + assert_eq!(stored["accounts"]["101"], 2); + assert_eq!(stored["accounts"]["102"], 1); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn media_click_selects_then_explicit_keys_preview_and_reply() { + use yazi_term::event::KeyCode; + let click = |column, row| MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: Modifiers::empty(), + }; + let mut app = ready_app(); + open_first(&mut app); + let mut media = message(21, 1, "image", false); + media.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: None, + fallback_emoji: None, + }); + app.handle_network(NetworkEvent::NewMessage(media)); + app.start_composing(); + app.handle_action(KeyAction::Character('x')); + app.set_message_hit_regions(vec![(10, 70, 8, (21, Some(0)))]); + app.set_composer_region((0, 80, 20, 23)); + assert!(app.handle_mouse(click(20, 8)).is_empty()); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.selected_message, Some(21)); + let commands = app.handle_key(&KeyEvent::new(KeyCode::Char('o'), Modifiers::empty())); + assert_eq!(app.mode, Mode::Preview); + assert!(matches!( + commands.first(), + Some(TelegramCommand::DownloadPreview { message_id: 21, .. }) + )); + let mut deleted = app.clone(); + deleted.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![21], + }); + deleted.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!(deleted.mode, Mode::Preview); + assert!(deleted.active_reply_target().is_none()); + app.handle_key(&KeyEvent::new(KeyCode::Escape, Modifiers::empty())); + app.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(21) + ); + app.handle_mouse(click(10, 21)); + assert_eq!(app.mode, Mode::Compose); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(21) + ); + assert_eq!(app.active_draft().unwrap().value(), "x"); + app.set_chat_hit_regions(vec![(0, 30, 4, 1)]); + app.handle_mouse(click(5, 4)); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.active_chat_id, Some(2)); + assert_eq!(app.drafts[&1].value(), "x"); + } + #[test] fn right_clicking_a_message_starts_a_reply_without_activating_its_actions() { let mut app = ready_app(); @@ -5301,4 +6371,488 @@ mod tests { } use crate::input::TextInput; + #[test] + fn folders_preserve_pins_membership_drafts_and_alias_destinations() { + use crate::folders::Folder; + let mut app = App { + screen: Screen::Main, + chats: vec![chat(1, "One"), chat(2, "Two"), chat(3, "Three")], + ..App::default() + }; + app.folders.push(Folder { + id: 9, + title: "Work".to_owned(), + pinned: vec![2], + include: vec![1], + ..Folder::default() + }); + app.active_chat_id = Some(3); + app.drafts.insert(3, TextInput::from_value("draft")); + app.run_binding("folder_next", 1); + assert_eq!( + app.visible_chats() + .iter() + .map(|chat| chat.id) + .collect::>(), + vec![2, 1] + ); + assert_eq!(app.active_chat_id, Some(3)); + assert_eq!(app.drafts.get(&3).unwrap().value(), "draft"); + app.keymap.chats.insert("three".to_owned(), 3); + app.run_binding("jump three", 1); + assert_eq!(app.folder_id, 0); + assert_eq!(app.selected_chat_entry().unwrap().id, 3); + app.handle_network(NetworkEvent::Folders(vec![Folder::all()])); + assert_eq!(app.folder_id, 0); + } + + #[test] + fn server_pins_preserve_selection_and_archive_has_its_own_order_and_aliases() { + use crate::{ + folders::Folder, + pins::{DialogAction, DialogPins, DialogScope}, + }; + let mut app = ready_app(); + app.chats = vec![chat(1, "One"), chat(2, "Two"), chat(3, "Archived")]; + app.chats[2].membership.archived = true; + app.folders.push(Folder::archive()); + app.selected_chat = 0; + app.focus = Focus::Chats; + app.handle_network(NetworkEvent::DialogPins(DialogPins { + main: vec![2, 1], + archive: vec![3], + })); + assert_eq!( + app.visible_chats() + .iter() + .map(|chat| chat.id) + .collect::>(), + vec![2, 1] + ); + assert_eq!(app.selected_chat_entry().unwrap().id, 1); + let commands = app.run_binding("pin_up", 1); + assert!(matches!( + commands.as_slice(), + [TelegramCommand::ChangeDialogPin { + chat_id: 1, + scope: DialogScope::Main, + action: DialogAction::MoveUp, + .. + }] + )); + assert!(app.run_binding("pin_up", 1).is_empty()); + app.handle_network(NetworkEvent::DialogPinFinished { + request_id: 1, + error: Some("PINNED_DIALOGS_TOO_MUCH".into()), + }); + assert_eq!(app.pins.dialogs.main, vec![2, 1]); + app.keymap.chats.insert("archived".into(), 3); + app.run_binding("jump archived", 1); + assert_eq!(app.folder_id, 1); + assert_eq!(app.active_chat_id, Some(3)); + assert_eq!(app.selected_chat_entry().unwrap().id, 3); + app.handle_network(NetworkEvent::ArchiveChanged { + chat_id: 3, + archived: false, + }); + assert!(app.visible_chats().is_empty()); + app.folder_id = 0; + assert_eq!(app.visible_chats().len(), 3); + } + + #[test] + fn message_pin_confirmation_defaults_and_failures_keep_the_selected_target() { + use crate::pins::MessageAction as PinAction; + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.chats[0].kind = crate::model::ChatKind::Direct; + app.account_user_id = Some(99); + app.focus = Focus::Conversation; + app.messages + .insert(1, vec![message(10, 1, "keep this", false)]); + app.selected_message = Some(10); + assert!(app.run_binding("pin", 1).is_empty()); + assert_eq!(app.mode, Mode::PinPrompt); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::ChangeMessagePin { + request_id, action, .. + }, + ] = commands.as_slice() + else { + panic!("pin mutation"); + }; + assert_eq!( + *action, + PinAction::Pin { + notify: false, + only_self: true + } + ); + assert!(app.run_binding("open", 1).is_empty()); + app.handle_network(NetworkEvent::MessagePinFinished { + chat_id: 1, + request_id: *request_id, + error: Some("CHAT_ADMIN_REQUIRED".into()), + }); + assert_eq!(app.mode, Mode::PinPrompt); + assert!(!app.active_messages()[0].pinned); + app.run_binding("cancel", 1); + app.chats[0].kind = crate::model::ChatKind::Group; + app.run_binding("pin", 1); + assert_eq!( + app.message_pins.prompt.as_ref().unwrap().options[0].1, + PinAction::Pin { + notify: true, + only_self: false + } + ); + } + + #[test] + fn pinned_navigation_preserves_drafts_and_does_not_resurrect_deleted_targets() { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.focus = Focus::Conversation; + app.drafts.insert(1, TextInput::from_value("unsent")); + let mut pin = message(20, 1, "target", false); + pin.pinned = true; + let commands = app.run_binding("pins", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() else { + panic!("pin list"); + }; + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *request_id, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, + }); + let commands = app.run_binding("open", 1); + let [TelegramCommand::LoadPinnedContext { request_id, .. }] = commands.as_slice() else { + panic!("pin context"); + }; + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![20], + }); + assert!( + app.handle_network(NetworkEvent::PinnedContext { + chat_id: 1, + message_id: 20, + request_id: *request_id, + messages: vec![pin] + }) + .is_empty() + ); + assert_eq!(app.mode, Mode::PinnedMessages); + assert!(app.message_pins.error.as_ref().unwrap().contains("deleted")); + assert!(app.active_messages().iter().all(|message| message.id != 20)); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + let commands = app.run_binding("refresh", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() else { + panic!("refresh pins"); + }; + let mut pin = message(21, 1, "another", false); + pin.pinned = true; + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *request_id, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, + }); + let commands = app.run_binding("open", 1); + let [TelegramCommand::LoadPinnedContext { request_id, .. }] = commands.as_slice() else { + panic!("pin context"); + }; + assert!( + app.handle_network(NetworkEvent::PinnedContext { + chat_id: 1, + message_id: 21, + request_id: *request_id, + messages: vec![pin] + }) + .is_empty() + ); + assert_eq!(app.selected_message, Some(21)); + assert!(app.message_scroll > 0); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + } + #[test] + fn pin_cache_invalidation_reloads_open_lists_and_rejects_old_context() { + for scope in [Some(1), None] { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.focus = Focus::Conversation; + app.drafts.insert(1, TextInput::from_value("unsent")); + let mut pin = message(20, 1, "obsolete", false); + pin.pinned = true; + let commands = app.run_binding("pins", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() + else { + panic!("pin list"); + }; + let old_page = crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }; + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *request_id, + page: old_page.clone(), + }); + app.handle_network(NetworkEvent::CacheInvalidated { chat_id: Some(2) }); + assert_eq!(app.message_pins.page.messages[0].id, 20); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::LoadPinnedContext { + request_id: old_request, + .. + }, + ] = commands.as_slice() + else { + panic!("pin context"); + }; + let commands = app.handle_network(NetworkEvent::CacheInvalidated { chat_id: scope }); + let request_id = commands + .iter() + .find_map(|command| match command { + TelegramCommand::LoadPinnedMessages { + request_id, + before: 0, + .. + } => Some(*request_id), + _ => None, + }) + .expect("reload the open pin list"); + assert_ne!(request_id, *old_request); + assert!(app.message_pins.page.messages.is_empty()); + assert!(app.pinned_summary().is_none()); + app.handle_network(NetworkEvent::PinnedContext { + chat_id: 1, + message_id: 20, + request_id: *old_request, + messages: vec![pin], + }); + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *old_request, + page: old_page, + }); + assert_eq!(app.mode, Mode::PinnedMessages); + assert!(app.active_messages().is_empty()); + assert!(app.message_pins.page.messages.is_empty()); + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id, + page: crate::pins::MessagePage { + total: Some(0), + ..Default::default() + }, + }); + assert!(!app.message_pins.loading); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + } + } + + #[test] + fn local_search_ignores_stale_results_and_preserves_drafts_and_query() { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.drafts.insert(1, TextInput::from_value("unsent")); + app.run_binding("search", 1); + app.update(AppEvent::Paste("error.*".to_owned())); + let commands = app.run_binding("open", 1); + let TelegramCommand::SearchCached(request) = &commands[0] else { + panic!("search request"); + }; + let page = crate::search::Page { + messages: vec![message(21, 1, "error!", false)], + next: None, + cached_messages: 30, + oldest: Some(1), + newest: Some(30), + }; + app.handle_network(NetworkEvent::SearchResults { + request_id: request.id.wrapping_sub(1), + page: page.clone(), + }); + assert!(app.search.page.is_none()); + app.handle_network(NetworkEvent::SearchResults { + request_id: request.id, + page, + }); + let commands = app.run_binding("open", 1); + let TelegramCommand::LoadCachedContext { request_id, .. } = commands[0] else { + panic!("cached context request"); + }; + let commands = app.handle_network(NetworkEvent::CachedContext { + request_id, + chat_id: 1, + message_id: 21, + messages: vec![ + message(20, 1, "before", false), + message(21, 1, "error!", false), + message(22, 1, "after", false), + ], + }); + assert!( + commands.is_empty(), + "opening search context must not mark unseen history read" + ); + assert_eq!(app.selected_message, Some(21)); + app.run_binding("search", 1); + assert_eq!(app.search.query.value(), "error.*"); + assert_eq!(app.drafts.get(&1).unwrap().value(), "unsent"); + } + #[test] + fn colors_follow_configuration_and_isolate_accounts_and_cancel_edits() { + use crate::appearance::{Target, TerminalColor}; + use ratatui::style::Color; + let mut app = ready_app(); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 101 }); + assert_eq!(app.color(Target::Chat(1)), Color::Reset); + assert_eq!( + app.color(Target::Folder(7)), + TerminalColor::folder_default(7).color() + ); + app.keymap = + crate::keymap::Keymap::parse("return { colors = { chats = { [1] = 'cyan' } } }") + .unwrap(); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 3; // Red, after follow/default/black. + app.handle_colors(KeyAction::Enter); + assert_eq!(app.color(Target::Chat(1)), Color::Red); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 102 }); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 101 }); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 0; + app.handle_colors(KeyAction::Escape); + assert_eq!(app.color(Target::Chat(1)), Color::Red); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 0; + app.handle_colors(KeyAction::Enter); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); + } + #[test] + fn reveal_downloads_once_and_rejects_stale_completion_after_media_edit() { + use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind}; + let mut app = ready_app(); + open_first(&mut app); + let mut attached = message(21, 1, "photo", false); + attached.attachment = Some(Attachment { + source_id: Some(55), + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: Some(4), + fallback_emoji: None, + }); + app.messages.insert(1, vec![attached.clone()]); + app.selected_message = Some(21); + let mut key = KeyEvent { + code: KeyCode::Char('O'), + modifiers: Modifiers::SHIFT, + kind: KeyEventKind::Repeat, + ..KeyEvent::default() + }; + assert!(app.handle_key(&key).is_empty()); + key.kind = KeyEventKind::Press; + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { + request_id: 1, + media_id: Some(55), + .. + }] + )); + assert!(app.handle_key(&key).is_empty()); + attached.text = "edited caption".to_owned(); + app.handle_network(NetworkEvent::MessageUpdated(attached.clone())); + assert_eq!(app.downloading_attachments.get(&(1, 21)), Some(&1)); + attached.attachment.as_mut().unwrap().source_id = Some(56); + app.handle_network(NetworkEvent::MessageUpdated(attached)); + assert!(!app.reveal_after_download.contains(&(1, 21))); + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { + request_id: 2, + media_id: Some(56), + .. + }] + )); + let absent = std::env::temp_dir() + .join(format!("termgram-missing-{}", std::process::id())) + .join("photo.jpg"); + app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, + chat_id: 1, + message_id: 21, + path: absent.clone(), + }); + assert_eq!(app.downloading_attachments.get(&(1, 21)), Some(&2)); + assert!(!app.downloaded_attachments.contains_key(&(1, 21))); + app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 2, + chat_id: 1, + message_id: 21, + path: absent, + }); + assert!( + app.status_message + .as_deref() + .unwrap() + .starts_with("Could not reveal attachment") + ); + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { request_id: 3, .. }] + )); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![21], + }); + assert!(app.reveal_after_download.is_empty()); + assert!(app.downloading_attachments.is_empty()); + } + #[test] + fn old_message_edits_and_latest_deletions_do_not_leave_stale_dialog_previews() { + let mut app = ready_app(); + app.chats[0].last_message_id = Some(100); + app.chats[0].last_activity = Some(Utc.timestamp_opt(100, 0).unwrap()); + app.chats[0].last_message = "latest".to_owned(); + app.messages.insert(1, vec![message(21, 1, "old", false)]); + app.handle_network(NetworkEvent::MessageUpdated(message( + 21, + 1, + "edited old", + false, + ))); + assert_eq!(app.chats[0].last_message, "latest"); + app.handle_network(NetworkEvent::DialogsLoading); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![100], + }); + assert!(app.chats[0].last_message.is_empty()); + let mut stale = app.chats[0].clone(); + stale.last_message_id = Some(100); + stale.last_message = "deleted".to_owned(); + app.handle_network(NetworkEvent::Dialogs(vec![stale])); + assert!(app.chats[0].last_message.is_empty()); + app.handle_network(NetworkEvent::DialogsLoading); + let mut fresh = app.chats[0].clone(); + fresh.last_message_id = Some(99); + fresh.last_message = "previous live message".to_owned(); + app.handle_network(NetworkEvent::Dialogs(vec![fresh])); + assert_eq!(app.chats[0].last_message, "previous live message"); + } } diff --git a/src/app/appearance.rs b/src/app/appearance.rs new file mode 100644 index 0000000..0f1360f --- /dev/null +++ b/src/app/appearance.rs @@ -0,0 +1,141 @@ +use super::{App, Focus, KeyAction, Mode, TelegramCommand}; +use crate::appearance::{Preferences, Target, TerminalColor}; +use anyhow::Result; + +#[derive(Clone)] +pub struct Picker { + pub target: Target, + pub label: String, + pub selection: usize, + previous_mode: Mode, +} + +impl App { + /// # Errors + /// Leaves existing managed preferences untouched when they cannot be read. + pub fn load_appearance(&mut self) -> Result<()> { + if let Some(path) = &self.settings_path { + self.appearance = Preferences::load(&path.with_file_name("appearance.json"))?; + } + Ok(()) + } + #[must_use] + pub fn color(&self, target: Target) -> ratatui::style::Color { + self.account_user_id + .and_then(|account| self.appearance.accounts.get(&account)) + .and_then(|colors| colors.get(target)) + .or_else(|| self.keymap.colors.get(target)) + .unwrap_or_else(|| match target { + Target::Chat(_) => TerminalColor::Default, + Target::Folder(id) => TerminalColor::folder_default(id), + }) + .color() + } + pub(super) fn begin_color_picker(&mut self, folder: bool) -> Vec { + let target = if folder { + self.folders + .iter() + .find(|folder| folder.id == self.folder_id) + .map(|folder| { + ( + Target::Folder(folder.id), + format!("Folder: {}", folder.title), + ) + }) + } else { + (if self.focus == Focus::Chats { + self.selected_chat_entry() + } else { + self.active_chat() + }) + .map(|chat| (Target::Chat(chat.id), format!("Chat: {}", chat.title))) + }; + if let Some((target, label)) = target { + let selected = self + .account_user_id + .and_then(|account| self.appearance.accounts.get(&account)) + .and_then(|colors| colors.get(target)); + let selection = selected + .and_then(|color| { + TerminalColor::ALL + .iter() + .position(|candidate| *candidate == color) + }) + .map_or(0, |index| index + 1); + self.status_message = None; + self.color_picker = Some(Picker { + target, + label, + selection, + previous_mode: self.mode, + }); + self.mode = Mode::Colors; + } + Vec::new() + } + pub(super) fn handle_colors(&mut self, action: KeyAction) -> Vec { + if action == KeyAction::Escape { + self.mode = self + .color_picker + .as_ref() + .map_or(Mode::Navigate, |picker| picker.previous_mode); + self.color_picker = None; + return Vec::new(); + } + let Some(picker) = &mut self.color_picker else { + return Vec::new(); + }; + match action { + KeyAction::Up => picker.selection = picker.selection.saturating_sub(1), + KeyAction::Down => { + picker.selection = picker + .selection + .saturating_add(1) + .min(TerminalColor::ALL.len()); + } + KeyAction::Enter => { + let target = picker.target; + let previous_mode = picker.previous_mode; + let value = picker + .selection + .checked_sub(1) + .map(|index| TerminalColor::ALL[index]); + match self.save_color(target, value) { + Ok(()) => { + self.color_picker = None; + self.mode = previous_mode; + self.status_message = None; + } + Err(error) => { + self.status_message = Some(format!("Color was not saved: {error:#}")); + } + } + } + _ => {} + } + Vec::new() + } + fn save_color(&mut self, target: Target, value: Option) -> Result<()> { + let account = self + .account_user_id + .ok_or_else(|| anyhow::anyhow!("account identity is not available yet"))?; + let path = self + .settings_path + .as_ref() + .map(|path| path.with_file_name("appearance.json")); + let mut preferences = path.as_ref().map_or_else( + || Ok(self.appearance.clone()), + |path| Preferences::load(path), + )?; + preferences + .accounts + .entry(account) + .or_default() + .set(target, value); + if let Some(path) = path { + preferences.save(&path)?; + } + self.appearance = preferences; + Ok(()) + } +} diff --git a/src/app/attachments.rs b/src/app/attachments.rs new file mode 100644 index 0000000..93685fd --- /dev/null +++ b/src/app/attachments.rs @@ -0,0 +1,101 @@ +use super::{App, Path, PathBuf, TelegramCommand, reveal_path, sanitize_terminal_line}; +use crate::model::ChatId; + +impl App { + pub(super) fn preview_selected_media(&mut self) -> Vec { + let message = self.selected_message.and_then(|id| { + self.active_messages() + .iter() + .find(|message| message.id == id) + .cloned() + }); + let Some(message) = message.filter(|message| { + message.id > 0 + && message + .attachment + .as_ref() + .is_some_and(crate::model::Attachment::supports_preview) + }) else { + self.status_message = Some("Select an image or sticker to preview".to_owned()); + return Vec::new(); + }; + self.activate_attachment(message.chat_id, message.id, &message) + } + + pub(super) fn queue_download( + &mut self, + chat_id: ChatId, + message_id: i32, + media_id: Option, + ) -> Vec { + if self + .downloading_attachments + .contains_key(&(chat_id, message_id)) + { + self.status_message = Some("Attachment is downloading…".to_owned()); + return Vec::new(); + } + let request_id = self.next_download_request_id; + self.next_download_request_id = request_id.wrapping_add(1).max(1); + self.downloading_attachments + .insert((chat_id, message_id), request_id); + self.status_message = Some("Downloading attachment…".to_owned()); + vec![TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + media_id, + }] + } + + pub(super) fn reveal_selected_attachment(&mut self) -> Vec { + let message = self + .active_messages() + .iter() + .find(|message| Some(message.id) == self.selected_message) + .or_else(|| self.active_messages().last()) + .cloned(); + let Some(message) = + message.filter(|message| message.id > 0 && message.attachment.is_some()) + else { + self.status_message = + Some("Select a downloaded or downloadable attachment first".to_owned()); + return Vec::new(); + }; + let key = (message.chat_id, message.id); + let path: Option = self.downloaded_attachments.get(&key).cloned().or_else(|| { + message + .attachment + .as_ref() + .filter(|attachment| !attachment.preview_uses_thumbnail()) + .and_then(|_| self.media_previews.get(&key)) + .and_then(|preview| preview.path.clone()) + }); + if let Some(path) = path.filter(|path| path.is_file()) { + self.reveal_download(&path); + return Vec::new(); + } + self.downloaded_attachments.remove(&key); + self.reveal_after_download.insert(key); + self.queue_download( + message.chat_id, + message.id, + message + .attachment + .and_then(|attachment| attachment.source_id), + ) + } + + pub(super) fn reveal_download(&mut self, path: &Path) { + self.status_message = Some(match reveal_path(path) { + Ok(()) => format!( + "Revealed {}", + sanitize_terminal_line(&path.display().to_string()) + ), + Err(error) => format!( + "Could not reveal attachment: {}", + sanitize_terminal_line(&error.to_string()) + ), + }); + } +} diff --git a/src/app/composition.rs b/src/app/composition.rs new file mode 100644 index 0000000..c6d88e4 --- /dev/null +++ b/src/app/composition.rs @@ -0,0 +1,94 @@ +//! Shared keyboard and pointer transitions into a per-chat draft. +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::{App, ChatId, Focus, Mode, TelegramCommand}; +use crate::config::{read_preferences, write_preferences}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct Navigation { + /// Telegram user IDs, not local account-slot indices. + accounts: BTreeMap, +} + +impl App { + /// Load navigation preferences without replacing malformed files. + /// + /// # Errors + /// Returns read or format errors from the managed preferences file. + pub fn load_navigation(&mut self) -> Result<()> { + if let Some(path) = &self.settings_path { + self.navigation = read_preferences(&path.with_file_name("navigation.json"))?; + } + Ok(()) + } + + pub(super) fn remember_chat(&mut self, chat_id: ChatId) { + let Some(account) = self.account_user_id else { + return; + }; + if self.navigation.accounts.insert(account, chat_id) == Some(chat_id) { + return; + } + let result = (|| -> Result<()> { + let Some(path) = &self.settings_path else { + return Ok(()); + }; + let path = path.with_file_name("navigation.json"); + let mut preferences: Navigation = read_preferences(&path)?; + preferences.accounts.insert(account, chat_id); + write_preferences(&path, &serde_json::to_vec_pretty(&preferences)?)?; + self.navigation = preferences; + Ok(()) + })(); + if let Err(error) = result { + self.status_message = Some(format!("Could not remember this chat: {error:#}")); + } + } + + pub(super) fn compose_or_reply(&mut self) -> Vec { + if self.focus == Focus::Conversation && self.selected_message.is_some() { + self.start_replying_to_selected() + } else { + self.start_composing() + } + } + + pub(super) fn start_composing(&mut self) -> Vec { + let mut commands = Vec::new(); + if self.active_chat_id.is_none() { + let remembered = self + .account_user_id + .and_then(|account| self.navigation.accounts.get(&account)) + .copied() + .filter(|id| self.chats.iter().any(|chat| chat.id == *id)); + if let Some(id) = remembered { + self.filter.clear(); + self.folder_id = self + .chats + .iter() + .find(|chat| chat.id == id) + .map_or(0, |chat| i32::from(chat.membership.archived)); + if self.folder_id == 1 && !self.folders.iter().any(|folder| folder.id == 1) { + self.folders.push(crate::folders::Folder::archive()); + } + self.preserve_chat_selection(Some(id)); + } + commands.extend(self.open_selected_chat()); + } + let Some(chat_id) = self.active_chat_id else { + self.status_message = + Some("No conversation available · open a chat after synchronization".to_owned()); + return commands; + }; + self.drafts.entry(chat_id).or_default(); + self.mode = Mode::Compose; + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.selected_message = None; + commands + } +} diff --git a/src/app/message_pins.rs b/src/app/message_pins.rs new file mode 100644 index 0000000..97c1645 --- /dev/null +++ b/src/app/message_pins.rs @@ -0,0 +1,567 @@ +use super::{App, Focus, Mode, TelegramCommand}; +use crate::{ + event::ConnectionStatus, + model::{ChatId, ChatKind, Message, sanitize_terminal_line}, + pins::{MessageAction, MessagePage}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub chat: Option, + pub page: MessagePage, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub prompt: Option, + pub head: Option<(ChatId, MessagePage)>, + pub starts: Vec, + request_id: u64, + next_request: u64, + pending_mutation: Option<(ChatId, u64)>, + dirty: bool, + opening: Option, +} + +#[derive(Clone)] +pub struct Prompt { + pub chat_id: ChatId, + pub message_id: i32, + pub title: String, + pub preview: String, + pub options: Vec<(String, MessageAction)>, + pub selection: usize, + previous_mode: Mode, +} + +impl App { + pub(super) fn invalidate_message_pins( + &mut self, + chat_id: Option, + ) -> Vec { + let affected = |id: ChatId| chat_id.is_none_or(|chat| chat == id); + let state = &mut self.message_pins; + if state.head.as_ref().is_some_and(|(id, _)| affected(*id)) { + state.head = None; + } + if let Some(prompt) = &state.prompt + && affected(prompt.chat_id) + { + if self.mode == Mode::PinPrompt { + self.mode = prompt.previous_mode; + } + state.prompt = None; + } + if !state.chat.is_some_and(affected) { + return Vec::new(); + } + // In-flight mutations still complete, but old reads must not restore + // invalidated messages. Keep next_request monotonic across the reset. + state.request_id = 0; + state.page = MessagePage::default(); + state.starts = vec![0]; + state.selected = 0; + state.opening = None; + state.loading = false; + state.dirty = false; + state.error = None; + if self.mode == Mode::PinnedMessages { + self.load_pin_page(0) + } else { + Vec::new() + } + } + + pub(super) fn update_pin_preview(&mut self, message: &Message) { + let state = &mut self.message_pins; + if let Some((chat_id, page)) = &mut state.head + && *chat_id == message.chat_id + { + for current in &mut page.messages { + if current.id == message.id { + *current = message.clone(); + } + } + } + if state.chat == Some(message.chat_id) { + for current in &mut state.page.messages { + if current.id == message.id { + *current = message.clone(); + } + } + state.dirty |= state.loading && message.pinned; + } + } + pub(super) fn pin_context_chat(&self) -> Option { + (self.mode == Mode::PinnedMessages && self.message_pins.opening.is_some()) + .then_some(self.message_pins.chat) + .flatten() + } + pub(super) fn fail_pinned_messages(&mut self, chat_id: ChatId, request_id: u64, error: &str) { + if request_id != 0 + && self.message_pins.chat == Some(chat_id) + && self.message_pins.request_id == request_id + { + self.message_pins.loading = false; + self.message_pins.opening = None; + self.message_pins.error = Some(sanitize_terminal_line(error)); + } + } + + #[must_use] + pub fn pinned_summary(&self) -> Option<(&Message, Option)> { + if let Some((chat, page)) = &self.message_pins.head + && Some(*chat) == self.active_chat_id + { + return page.messages.first().map(|message| (message, page.total)); + } + self.active_messages() + .iter() + .rev() + .find(|message| message.pinned) + .map(|message| (message, None)) + } + pub(super) fn open_pinned_messages(&mut self) -> Vec { + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + self.message_pins.chat = Some(chat_id); + self.message_pins.starts = vec![0]; + self.message_pins.page = MessagePage::default(); + self.mode = Mode::PinnedMessages; + self.load_pin_page(0) + } + + fn load_pin_page(&mut self, before: i32) -> Vec { + let Some(chat_id) = self.message_pins.chat else { + return Vec::new(); + }; + let state = &mut self.message_pins; + state.next_request = state.next_request.wrapping_add(1).max(1); + state.request_id = state.next_request; + state.loading = true; + state.dirty = false; + state.error = None; + state.opening = None; + state.selected = 0; + vec![TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id: state.request_id, + }] + } + + pub(super) fn pin_binding( + &mut self, + action: &str, + count: usize, + ) -> Option> { + if self.mode == Mode::PinPrompt { + return self.pin_prompt_binding(action); + } + if self.mode != Mode::PinnedMessages { + return None; + } + match action { + "cancel" => { + self.mode = Mode::Navigate; + self.message_pins.request_id = 0; + self.message_pins.loading = false; + self.message_pins.opening = None; + } + "up" | "down" | "page_up" | "page_down" => { + let state = &mut self.message_pins; + let step = if action.starts_with("page_") { + count.saturating_mul(10) + } else { + count + }; + state.selected = if action.ends_with("up") { + state.selected.saturating_sub(step) + } else { + state + .selected + .saturating_add(step) + .min(state.page.messages.len().saturating_sub(1)) + }; + } + "pins_more" if !self.message_pins.loading => { + if let Some(next) = self.message_pins.page.next { + self.message_pins.starts.push(next); + return Some(self.load_pin_page(next)); + } + } + "pins_previous" if !self.message_pins.loading && self.message_pins.starts.len() > 1 => { + self.message_pins.starts.pop(); + return Some(self.load_pin_page(*self.message_pins.starts.last().unwrap_or(&0))); + } + "refresh" => { + return Some(self.load_pin_page(*self.message_pins.starts.last().unwrap_or(&0))); + } + "pin" => return Some(self.begin_message_pin(false)), + "unpin_all" => return Some(self.begin_message_pin(true)), + "open" => { + if let Some(message) = self + .message_pins + .page + .messages + .get(self.message_pins.selected) + { + let (chat_id, message_id) = (message.chat_id, message.id); + self.message_pins.opening = Some(message_id); + self.active_history_request = None; + self.history_changes.clear(); + self.history_read_max = 0; + self.message_pins.next_request = + self.message_pins.next_request.wrapping_add(1).max(1); + self.message_pins.request_id = self.message_pins.next_request; + self.message_pins.loading = true; + self.message_pins.error = None; + return Some(vec![TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id: self.message_pins.request_id, + }]); + } + } + _ => return None, + } + Some(Vec::new()) + } + + #[allow(clippy::too_many_lines)] + pub(super) fn begin_message_pin(&mut self, all: bool) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change message pins".to_owned()); + return Vec::new(); + } + if self.message_pins.pending_mutation.is_some() { + return Vec::new(); + } + let message = if self.mode == Mode::PinnedMessages { + self.message_pins + .page + .messages + .get(self.message_pins.selected) + } else if self.mode == Mode::Navigate && self.focus == Focus::Conversation { + self.active_messages() + .iter() + .find(|message| Some(message.id) == self.selected_message) + } else { + return Vec::new(); + }; + let Some(message) = message.filter(|message| message.id > 0) else { + self.status_message = Some("Select a sent message first".to_owned()); + return Vec::new(); + }; + let private = self + .active_chat() + .is_some_and(|chat| chat.kind == ChatKind::Direct); + let own = self.account_user_id == Some(message.chat_id); + let old = self + .message_pins + .head + .as_ref() + .filter(|(chat, _)| *chat == message.chat_id) + .and_then(|(_, page)| page.messages.first()) + .is_some_and(|head| head.id > message.id); + let (title, options) = if all { + ( + "Unpin all messages?", + vec![("Unpin all messages".to_owned(), MessageAction::UnpinAll)], + ) + } else if message.pinned { + ( + "Unpin this message?", + vec![("Unpin message".to_owned(), MessageAction::Unpin)], + ) + } else if private && !own { + ( + "Pin this message?", + vec![ + ( + "Pin only for me".to_owned(), + MessageAction::Pin { + notify: false, + only_self: true, + }, + ), + ( + "Pin for both participants".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + ), + ], + ) + } else if !private && !old { + ( + "Pin this message?", + vec![ + ( + "Pin and notify members".to_owned(), + MessageAction::Pin { + notify: true, + only_self: false, + }, + ), + ( + "Pin silently".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + ), + ], + ) + } else { + ( + if old { + "Pin this older message?" + } else { + "Pin this message?" + }, + vec![( + "Pin message".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + )], + ) + }; + self.message_pins.prompt = Some(Prompt { + chat_id: message.chat_id, + message_id: message.id, + title: title.to_owned(), + preview: sanitize_terminal_line(&format!( + "#{} {}: {}", + message.id, message.sender, message.text + )), + options, + selection: 0, + previous_mode: self.mode, + }); + self.message_pins.error = None; + self.mode = Mode::PinPrompt; + Vec::new() + } + + fn pin_prompt_binding(&mut self, action: &str) -> Option> { + if action == "cancel" { + if let Some(prompt) = self.message_pins.prompt.take() { + self.mode = prompt.previous_mode; + } + return Some(Vec::new()); + } + let Some(prompt) = self.message_pins.prompt.as_mut() else { + return Some(Vec::new()); + }; + if self.message_pins.pending_mutation.is_some() { + return Some(Vec::new()); + } + match action { + "up" => prompt.selection = prompt.selection.saturating_sub(1), + "down" => { + prompt.selection = + (prompt.selection + 1).min(prompt.options.len().saturating_sub(1)); + } + "open" => { + self.message_pins.next_request = + self.message_pins.next_request.wrapping_add(1).max(1); + let request_id = self.message_pins.next_request; + self.message_pins.pending_mutation = Some((prompt.chat_id, request_id)); + self.message_pins.error = None; + self.status_message = Some("Updating pinned messages…".to_owned()); + return Some(vec![TelegramCommand::ChangeMessagePin { + chat_id: prompt.chat_id, + message_id: prompt.message_id, + action: prompt.options[prompt.selection].1, + request_id, + }]); + } + _ => return None, + } + Some(Vec::new()) + } + + pub(super) fn finish_message_pin( + &mut self, + chat_id: ChatId, + request_id: u64, + error: Option, + ) -> Vec { + if self.message_pins.pending_mutation != Some((chat_id, request_id)) { + return Vec::new(); + } + self.message_pins.pending_mutation = None; + if let Some(error) = error { + let error = sanitize_terminal_line(&error); + self.message_pins.error = Some(error.clone()); + self.status_message = Some(error); + return Vec::new(); + } + if let Some(prompt) = self.message_pins.prompt.take() + && self.mode == Mode::PinPrompt + { + self.mode = prompt.previous_mode; + } + self.status_message = Some("Pinned messages updated".to_owned()); + if self.mode == Mode::PinnedMessages { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + Vec::new() + } + + pub(super) fn finish_pinned_messages( + &mut self, + chat_id: ChatId, + request_id: u64, + page: MessagePage, + ) -> Vec { + if !self.message_pins.dirty + && page.before == 0 + && self.active_chat_id == Some(chat_id) + && (request_id == 0 + || (self.mode == Mode::PinnedMessages + && self.message_pins.request_id == request_id)) + { + self.message_pins.head = Some((chat_id, page.clone())); + } + if request_id == 0 + || self.message_pins.chat != Some(chat_id) + || self.message_pins.request_id != request_id + { + return Vec::new(); + } + if self.message_pins.dirty && page.total.is_some() { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + self.message_pins.loading = + page.total.is_none() && self.connection == ConnectionStatus::Online; + self.message_pins.page = page; + self.message_pins.selected = self + .message_pins + .selected + .min(self.message_pins.page.messages.len().saturating_sub(1)); + Vec::new() + } + + pub(super) fn pinned_messages_deleted( + &mut self, + channel_id: Option, + ids: &[i32], + ) -> Vec { + let mut commands = Vec::new(); + let mut chats = self + .message_pins + .chat + .into_iter() + .chain(self.message_pins.head.as_ref().map(|(id, _)| *id)) + .collect::>(); + chats.sort_unstable(); + chats.dedup(); + for chat_id in chats { + if channel_id.map_or(chat_id > -1_000_000_000_000, |id| id == chat_id) { + commands.extend(self.pin_messages_changed(chat_id, Some(ids), false)); + } + } + commands + } + + pub(super) fn pin_messages_changed( + &mut self, + chat_id: ChatId, + ids: Option<&[i32]>, + pinned: bool, + ) -> Vec { + let affected = |id: i32| ids.is_none_or(|ids| ids.contains(&id)); + let changed: Vec<_> = self + .messages + .get_mut(&chat_id) + .into_iter() + .flatten() + .filter(|message| affected(message.id)) + .map(|message| { + message.pinned = pinned; + message.clone() + }) + .collect(); + for message in changed { + self.track_snapshot_changes(&crate::event::NetworkEvent::MessageUpdated(message)); + } + if let Some((chat, page)) = &mut self.message_pins.head + && *chat == chat_id + && !pinned + { + page.messages.retain(|message| !affected(message.id)); + } + if self.message_pins.chat == Some(chat_id) { + if !pinned { + self.message_pins + .page + .messages + .retain(|message| !affected(message.id)); + } + if self.message_pins.loading { + self.message_pins.dirty = true; + } else if self.mode == Mode::PinnedMessages { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + } + Vec::new() + } + + pub(super) fn finish_pinned_context( + &mut self, + chat_id: ChatId, + message_id: i32, + request_id: u64, + mut messages: Vec, + ) { + if self.mode != Mode::PinnedMessages + || self.message_pins.chat != Some(chat_id) + || self.message_pins.request_id != request_id + { + return; + } + messages.retain(|message| !self.history_changes.contains_key(&message.id)); + messages.extend( + self.history_changes + .values() + .flatten() + .filter(|message| message.id <= message_id) + .cloned(), + ); + if !messages.iter().any(|message| message.id == message_id) { + self.fail_pinned_messages(chat_id, request_id, "Pinned message was deleted"); + return; + } + self.message_pins.opening = None; + self.message_pins.loading = false; + self.mode = Mode::Navigate; + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.active_history_request = None; + self.older_motion = None; + self.loading_history = false; + self.history_target_message = None; + self.history_changes.clear(); + self.merge_history(chat_id, messages, None); + self.browsing_older = true; + self.new_messages_while_scrolled = 0; + self.new_messages_to_anchor = 0; + self.select_message_id(message_id, true); + self.message_scroll = self.message_scroll.max(1); + self.status_message = Some(format!( + "Pinned message · {} latest · {} pins", + self.keymap + .hint(crate::keymap::Context::Conversation, "latest"), + self.keymap + .hint(crate::keymap::Context::Conversation, "pins") + )); + } +} diff --git a/src/app/pins.rs b/src/app/pins.rs new file mode 100644 index 0000000..8e96f72 --- /dev/null +++ b/src/app/pins.rs @@ -0,0 +1,127 @@ +use super::{App, Focus, Mode, TelegramCommand}; +use crate::{ + event::ConnectionStatus, + pins::{DialogAction, DialogPins, DialogScope}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub dialogs: DialogPins, + pending_dialog: Option, + next_request: u64, + archived_action: bool, +} + +impl App { + pub(super) fn toggle_archive(&mut self) -> Vec { + if self.focus != Focus::Chats || self.mode != Mode::Navigate { + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change the archive".to_owned()); + return Vec::new(); + } + if self.pins.pending_dialog.is_some() { + return Vec::new(); + } + let Some(chat) = self.selected_chat_entry() else { + return Vec::new(); + }; + let (chat_id, archived) = (chat.id, !chat.membership.archived); + self.pins.next_request += 1; + let request_id = self.pins.next_request; + self.pins.pending_dialog = Some(request_id); + self.pins.archived_action = true; + self.status_message = Some("Updating Telegram archive…".to_owned()); + vec![TelegramCommand::SetArchived { + chat_id, + archived, + request_id, + }] + } + #[must_use] + pub fn chat_pin_position(&self, chat_id: i64) -> Option { + let order = match self.folder_id { + 0 => &self.pins.dialogs.main, + 1 => &self.pins.dialogs.archive, + id => &self.folders.iter().find(|folder| folder.id == id)?.pinned, + }; + order.iter().position(|id| *id == chat_id) + } + + pub(super) fn change_chat_pin(&mut self, run: &str) -> Vec { + if self.focus != Focus::Chats || self.mode != Mode::Navigate { + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change pins".to_owned()); + return Vec::new(); + } + if self.pins.pending_dialog.is_some() { + return Vec::new(); + } + let Some(chat) = self.selected_chat_entry() else { + return Vec::new(); + }; + let chat_id = chat.id; + let pinned = self.chat_pin_position(chat_id).is_some(); + let action = match run { + "pin" => DialogAction::Set(!pinned), + "pin_up" if pinned => DialogAction::MoveUp, + "pin_down" if pinned => DialogAction::MoveDown, + _ => { + self.status_message = Some("Pin the chat before reordering it".to_owned()); + return Vec::new(); + } + }; + let scope = match self.folder_id { + 0 => DialogScope::Main, + 1 => DialogScope::Archive, + id => DialogScope::Filter(id), + }; + self.pins.next_request += 1; + let request_id = self.pins.next_request; + self.pins.pending_dialog = Some(request_id); + self.pins.archived_action = false; + self.status_message = Some("Updating Telegram pins…".to_owned()); + vec![TelegramCommand::ChangeDialogPin { + chat_id, + scope, + action, + request_id, + }] + } + + pub(super) fn finish_dialog_pin(&mut self, request_id: u64, error: Option) { + if self.pins.pending_dialog == Some(request_id) { + self.pins.pending_dialog = None; + let subject = if self.pins.archived_action { + "archive" + } else { + "pins" + }; + self.status_message = Some(error.map_or_else( + || format!("Telegram {subject} updated"), + |error| { + format!( + "Could not update {subject}: {}", + crate::model::sanitize_terminal_line(&error) + ) + }, + )); + } + } + + pub(super) fn preserve_chat_selection(&mut self, selected_id: Option) { + self.selected_chat = selected_id + .and_then(|id| { + self.filtered_chat_indices() + .iter() + .position(|&index| self.chats[index].id == id) + }) + .unwrap_or_else(|| { + self.selected_chat + .min(self.filtered_chat_indices().len().saturating_sub(1)) + }); + } +} diff --git a/src/app/search.rs b/src/app/search.rs new file mode 100644 index 0000000..13eb856 --- /dev/null +++ b/src/app/search.rs @@ -0,0 +1,271 @@ +use super::{App, Focus, KeyAction, Mode, TelegramCommand, TextInput}; +use crate::{ + model::{ChatId, Message}, + search::{Cursor, Page, Request}, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Scope { + #[default] + Chat, + Folder, + Account, +} + +#[derive(Clone, Default)] +pub struct State { + pub query: TextInput, + pub editing: bool, + pub scope: Scope, + pub scope_label: String, + pub page: Option, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub page_number: usize, + request_id: u64, + chats: Option>, + starts: Vec>, +} + +impl App { + pub(super) fn open_search(&mut self) -> Vec { + if self.search.query.is_empty() { + self.search.editing = true; + self.search.scope = if self.active_chat_id.is_some() { + Scope::Chat + } else { + Scope::Folder + }; + } + self.mode = Mode::Search; + if self.search.page.is_none() { + self.update_search_scope(); + } + Vec::new() + } + + fn update_search_scope(&mut self) { + (self.search.chats, self.search.scope_label) = match self.search.scope { + Scope::Chat => ( + Some(self.active_chat_id.into_iter().collect()), + self.active_chat().map_or_else( + || "No open chat".to_owned(), + |chat| format!("Chat: {}", chat.title), + ), + ), + Scope::Folder => { + let folder = self + .folders + .iter() + .find(|folder| folder.id == self.folder_id); + ( + Some( + self.chats + .iter() + .filter(|chat| { + folder.is_none_or(|folder| { + folder.contains(chat, chrono::Utc::now().timestamp()) + }) + }) + .map(|chat| chat.id) + .collect(), + ), + format!( + "Folder: {}", + folder.map_or("All chats", |folder| folder.title.as_str()) + ), + ) + } + Scope::Account => (None, "This account".to_owned()), + }; + } + + pub(super) fn search_binding( + &mut self, + action: &str, + count: usize, + ) -> Option> { + match action { + "cancel" => { + self.mode = Mode::Navigate; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + Some(vec![TelegramCommand::CancelSearch]) + } + "open" if self.search.editing => Some(self.search_submit(None, true)), + "open" => { + let message = self + .search + .page + .as_ref()? + .messages + .get(self.search.selected)?; + self.search.request_id = self.search.request_id.wrapping_add(1); + self.search.loading = true; + Some(vec![TelegramCommand::LoadCachedContext { + chat_id: message.chat_id, + message_id: message.id, + request_id: self.search.request_id, + }]) + } + "search_scope" => { + self.search.scope = match self.search.scope { + Scope::Chat => Scope::Folder, + Scope::Folder => Scope::Account, + Scope::Account => Scope::Chat, + }; + self.update_search_scope(); + self.search.page = None; + self.search.editing = true; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + Some(vec![TelegramCommand::CancelSearch]) + } + "search_query" => { + self.search.editing = true; + Some(Vec::new()) + } + "search_more" if !self.search.loading => { + if let Some(next) = self.search.page.as_ref().and_then(|page| page.next) { + self.search.starts.push(Some(next)); + self.search.page_number += 1; + Some(self.search_submit(Some(next), false)) + } else { + Some(Vec::new()) + } + } + "search_previous" if !self.search.loading && self.search.page_number > 1 => { + self.search.starts.pop(); + self.search.page_number -= 1; + Some(self.search_submit(self.search.starts.last().copied().flatten(), false)) + } + "up" | "down" | "page_up" | "page_down" => { + let length = self + .search + .page + .as_ref() + .map_or(0, |page| page.messages.len()); + if length > 0 { + self.search.editing = false; + let amount = if action.starts_with("page_") { + count.saturating_mul(10) + } else { + count + }; + self.search.selected = if action.ends_with("up") { + self.search.selected.saturating_sub(amount) + } else { + self.search.selected.saturating_add(amount).min(length - 1) + }; + } + Some(Vec::new()) + } + _ => None, + } + } + + fn search_submit(&mut self, before: Option, fresh: bool) -> Vec { + self.search.request_id = self.search.request_id.wrapping_add(1); + if fresh { + self.search.starts = vec![None]; + self.search.page_number = 1; + self.search.page = None; + self.update_search_scope(); + } + self.search.selected = 0; + self.search.loading = true; + self.search.editing = false; + self.search.error = None; + vec![TelegramCommand::SearchCached(Request { + id: self.search.request_id, + pattern: self.search.query.value().to_owned(), + chats: self.search.chats.clone(), + before, + })] + } + + pub(super) fn edit_search(&mut self, action: KeyAction) -> Vec { + if !self.search.editing { + return Vec::new(); + } + let before = self.search.query.value().to_owned(); + let query = &mut self.search.query; + match action { + KeyAction::Character(c) => query.insert(c), + KeyAction::Backspace => _ = query.backspace(), + KeyAction::Delete => _ = query.delete(), + KeyAction::Left => _ = query.move_left(), + KeyAction::Right => _ = query.move_right(), + KeyAction::Home => query.move_home(), + KeyAction::End => query.move_end(), + KeyAction::Clear => query.clear(), + KeyAction::DeleteWord => _ = query.delete_word_before(), + _ => {} + } + if before == query.value() { + Vec::new() + } else { + self.search_edited() + } + } + + pub(super) fn search_edited(&mut self) -> Vec { + self.search.page = None; + self.search.error = None; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + vec![TelegramCommand::CancelSearch] + } + + pub(super) fn finish_search(&mut self, request_id: u64, result: Result) { + if self.search.request_id != request_id || self.mode != Mode::Search { + return; + } + self.search.loading = false; + match result { + Ok(page) => { + self.search.page = Some(page); + self.search.error = None; + } + Err(error) => { + self.search.error = Some(crate::model::sanitize_terminal_text(&error)); + self.search.editing = true; + } + } + } + + pub(super) fn open_search_context( + &mut self, + request_id: u64, + chat_id: ChatId, + message_id: i32, + messages: Vec, + ) { + if self.search.request_id != request_id || self.mode != Mode::Search { + return; + } + self.search.loading = false; + self.mode = Mode::Navigate; + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.active_history_request = None; + self.older_motion = None; + self.loading_history = false; + self.active_chat_id = Some(chat_id); + self.history_target_message = None; + self.merge_history(chat_id, messages, None); + self.browsing_older = true; + self.new_messages_while_scrolled = 0; + self.new_messages_to_anchor = 0; + self.select_message_id(message_id, true); + self.message_scroll = self.message_scroll.max(1); + self.status_message = Some(format!( + "Cached search result · {} latest · {} results", + self.keymap + .hint(crate::keymap::Context::Conversation, "latest"), + self.keymap + .hint(crate::keymap::Context::Conversation, "search") + )); + } +} diff --git a/src/appearance.rs b/src/appearance.rs new file mode 100644 index 0000000..1effc05 --- /dev/null +++ b/src/appearance.rs @@ -0,0 +1,205 @@ +//! Account-specific color overrides, separate from discardable caches and Lua. +use crate::model::ChatId; +use anyhow::Result; +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalColor { + #[default] + Default, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, +} + +impl TerminalColor { + pub const ALL: [Self; 17] = [ + Self::Default, + Self::Black, + Self::Red, + Self::Green, + Self::Yellow, + Self::Blue, + Self::Magenta, + Self::Cyan, + Self::Gray, + Self::DarkGray, + Self::LightRed, + Self::LightGreen, + Self::LightYellow, + Self::LightBlue, + Self::LightMagenta, + Self::LightCyan, + Self::White, + ]; + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Default => "Terminal default", + Self::Black => "Black", + Self::Red => "Red", + Self::Green => "Green", + Self::Yellow => "Yellow", + Self::Blue => "Blue", + Self::Magenta => "Magenta", + Self::Cyan => "Cyan", + Self::Gray => "Gray", + Self::DarkGray => "Dark gray", + Self::LightRed => "Light red", + Self::LightGreen => "Light green", + Self::LightYellow => "Light yellow", + Self::LightBlue => "Light blue", + Self::LightMagenta => "Light magenta", + Self::LightCyan => "Light cyan", + Self::White => "White", + } + } + #[must_use] + pub const fn color(self) -> Color { + match self { + Self::Default => Color::Reset, + Self::Black => Color::Black, + Self::Red => Color::Red, + Self::Green => Color::Green, + Self::Yellow => Color::Yellow, + Self::Blue => Color::Blue, + Self::Magenta => Color::Magenta, + Self::Cyan => Color::Cyan, + Self::Gray => Color::Gray, + Self::DarkGray => Color::DarkGray, + Self::LightRed => Color::LightRed, + Self::LightGreen => Color::LightGreen, + Self::LightYellow => Color::LightYellow, + Self::LightBlue => Color::LightBlue, + Self::LightMagenta => Color::LightMagenta, + Self::LightCyan => Color::LightCyan, + Self::White => Color::White, + } + } + #[must_use] + pub fn folder_default(id: i32) -> Self { + const COLORS: [TerminalColor; 6] = [ + TerminalColor::Cyan, + TerminalColor::Yellow, + TerminalColor::Magenta, + TerminalColor::Green, + TerminalColor::Blue, + TerminalColor::Red, + ]; + COLORS[usize::try_from(id.rem_euclid(6)).unwrap_or_default()] + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Colors { + pub chats: BTreeMap, + pub folders: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Target { + Chat(ChatId), + Folder(i32), +} +impl Colors { + #[must_use] + pub fn get(&self, target: Target) -> Option { + match target { + Target::Chat(id) => self.chats.get(&id), + Target::Folder(id) => self.folders.get(&id), + } + .copied() + } + pub fn set(&mut self, target: Target, value: Option) { + match target { + Target::Chat(id) => { + if let Some(value) = value { + self.chats.insert(id, value); + } else { + self.chats.remove(&id); + } + } + Target::Folder(id) => { + if let Some(value) = value { + self.folders.insert(id, value); + } else { + self.folders.remove(&id); + } + } + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Preferences { + pub accounts: BTreeMap, +} +impl Preferences { + /// # Errors + /// Returns read or format errors without replacing the existing preferences. + pub fn load(path: &Path) -> Result { + crate::config::read_preferences(path) + } + /// # Errors + /// Returns write errors; uses the existing atomic settings writer. + pub fn save(&self, path: &Path) -> Result<()> { + crate::config::write_preferences(path, &serde_json::to_vec_pretty(self)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn appearance_roundtrip_uses_durable_account_ids_and_keeps_bad_files() { + let dir = std::env::temp_dir().join(format!("termgram-appearance-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("appearance.json"); + let mut preferences = Preferences::default(); + preferences + .accounts + .entry(101) + .or_default() + .set(Target::Chat(5), Some(TerminalColor::Magenta)); + preferences + .accounts + .entry(102) + .or_default() + .set(Target::Folder(5), Some(TerminalColor::Cyan)); + preferences.save(&path).unwrap(); + assert_eq!(Preferences::load(&path).unwrap(), preferences); + preferences + .accounts + .get_mut(&101) + .unwrap() + .set(Target::Chat(5), None); + preferences.save(&path).unwrap(); + assert!( + Preferences::load(&path).unwrap().accounts[&101] + .chats + .is_empty() + ); + std::fs::write(&path, "invalid").unwrap(); + assert!(Preferences::load(&path).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "invalid"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..a9ee06c --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,1278 @@ +//! Account-local, discardable message storage. Authentication stays in the +//! Telegram session; user preferences do not belong in this database. + +use std::{collections::BTreeMap, path::Path, time::Duration}; + +use anyhow::{Context, Result, bail}; +use libsql::{Connection, Database, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; + +use crate::{ + event::NetworkEvent, + model::{Chat, ChatId, Delivery, Message}, +}; + +const SCHEMA_VERSION: i64 = 5; +const MAX_MESSAGES: i64 = 100_000; +const MAX_CHAT_MESSAGES: i64 = 5_000; + +/// SDK-independent cursor. Only complete update batches may advance it. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct SyncCursor { + pub pts: i32, + pub qts: i32, + pub date: i32, + pub seq: i32, + pub channels: Vec<(i64, i32)>, +} + +pub struct Store { + _database: Database, + connection: Connection, + revision: i64, + dialog_revision: i64, + history_revisions: BTreeMap<(ChatId, u64), i64>, + pin_revisions: BTreeMap<(ChatId, u64), i64>, + downloads: BTreeMap<(ChatId, i32), (u64, Option)>, + owner: std::sync::Arc, +} + +impl Store { + /// # Errors + /// Returns filesystem, migration or SQLite errors; never discards a cache + /// merely because it could not be read. + pub async fn open(path: &Path) -> Result { + let mut lock_path = path.as_os_str().to_os_string(); + lock_path.push("-lock"); + prepare_file(Path::new(&lock_path))?; + let owner = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(Path::new(&lock_path))?; + owner + .try_lock() + .context("this account cache is already open in another Termgram process")?; + prepare_file(path)?; + let database = libsql::Builder::new_local(path).build().await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_secs(5))?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + let version = transaction + .query("PRAGMA user_version", ()) + .await? + .next() + .await? + .context("missing schema version")? + .get::(0)?; + if version > SCHEMA_VERSION { + bail!("message cache was created by a newer Termgram version"); + } + if version == 0 { + transaction.execute_batch( + "CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE chats (id INTEGER PRIMARY KEY, data TEXT NOT NULL, revision INTEGER NOT NULL); + CREATE TABLE messages (chat_id INTEGER NOT NULL, id INTEGER NOT NULL, + data TEXT, timestamp INTEGER NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY (chat_id, id)); + CREATE INDEX messages_recent ON messages(timestamp DESC); + CREATE TABLE global_deletions (id INTEGER PRIMARY KEY, revision INTEGER NOT NULL); + CREATE TABLE attachments (chat_id INTEGER NOT NULL, message_id INTEGER NOT NULL, + path TEXT NOT NULL, PRIMARY KEY(chat_id, message_id)); + PRAGMA user_version = 1;" + ).await?; + } + if version < 2 { + transaction.execute_batch("CREATE TABLE history_pages (chat_id INTEGER NOT NULL, before_id INTEGER NOT NULL, oldest_id INTEGER NOT NULL, PRIMARY KEY(chat_id,before_id)); PRAGMA user_version=2;").await?; + } + if version < 3 { + transaction.execute_batch("CREATE INDEX messages_search ON messages(timestamp DESC,chat_id DESC,id DESC); PRAGMA user_version=3;").await?; + } + if version < 4 { + transaction.execute_batch("ALTER TABLE attachments ADD COLUMN source_id INTEGER; DELETE FROM attachments; PRAGMA user_version=4;").await?; + } + if version < 5 { + transaction.execute_batch("ALTER TABLE chats ADD COLUMN last_message_id INTEGER; CREATE INDEX chats_last_message ON chats(last_message_id); PRAGMA user_version=5;").await?; + } + transaction.commit().await?; + let revision = metadata(&connection, "revision") + .await? + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok(Self { + _database: database, + owner: std::sync::Arc::new(owner), + connection, + revision, + dialog_revision: revision, + history_revisions: BTreeMap::new(), + pin_revisions: BTreeMap::new(), + downloads: BTreeMap::new(), + }) + } + + pub(crate) fn owner(&self) -> std::sync::Arc { + self.owner.clone() + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn snapshot(&self) -> Result<(Option, Vec)> { + let name = metadata(&self.connection, "user_name").await?; + let mut rows = self.connection.query("SELECT data FROM chats", ()).await?; + let mut chats: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + chats.push(serde_json::from_str(&row.get::(0)?)?); + } + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + Ok((name, chats)) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn folders(&self) -> Result> { + Ok(metadata(&self.connection, "folders") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose()? + .unwrap_or_else(|| vec![crate::folders::Folder::all()])) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn dialog_pins(&self) -> Result { + Ok(metadata(&self.connection, "dialog_pins") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose()? + .unwrap_or_default()) + } + + /// # Errors + /// Returns database or message decoding errors. + pub async fn pinned_messages( + &self, + chat_id: ChatId, + before: i32, + ) -> Result { + let mut rows = self + .connection + .query( + "SELECT data FROM messages WHERE chat_id=?1 AND data IS NOT NULL + AND json_extract(data,'$.pinned')=1 AND (?2=0 OR id = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + let next = messages + .last() + .filter(|_| messages.len() == crate::pins::PAGE_SIZE) + .map(|message| message.id); + Ok(crate::pins::MessagePage { + messages, + before, + next, + total: None, + }) + } + + /// # Errors + /// Returns database errors. Missing files are treated as cache misses. + pub async fn attachment( + &self, + chat_id: ChatId, + message_id: i32, + media_id: Option, + ) -> Result> { + let row = self.connection.query("SELECT path FROM attachments WHERE chat_id=?1 AND message_id=?2 AND source_id IS ?3", params![chat_id, message_id, media_id]).await?.next().await?; + let path = row + .map(|row| row.get::(0).map(std::path::PathBuf::from)) + .transpose()?; + Ok(path.filter(|path| path.is_file())) + } + + /// Cached neighbors for a search hit, without triggering network reads. + /// # Errors + /// Returns an error when the hit was deleted or evicted since the search. + pub async fn context(&self, chat_id: ChatId, message_id: i32) -> Result> { + let mut messages = self + .history(chat_id, Some(message_id.saturating_add(1)), 40) + .await?; + if !messages.iter().any(|message| message.id == message_id) { + bail!("Search result is no longer cached; run the search again"); + } + let mut rows = self.connection.query("SELECT data FROM messages WHERE chat_id=?1 AND id>?2 AND data IS NOT NULL ORDER BY id LIMIT 40", params![chat_id, message_id]).await?; + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + Ok(messages) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn cursor(&self) -> Result { + metadata(&self.connection, "cursor") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map(Option::unwrap_or_default) + .map_err(Into::into) + } + + /// # Errors + /// Returns a database error. + pub async fn account_id(&self) -> Result> { + Ok(metadata(&self.connection, "account_id") + .await? + .and_then(|value| value.parse().ok())) + } + + /// Read a bounded page without any network dependency. + /// # Errors + /// Returns database or decoding errors. + pub async fn history( + &self, + chat_id: ChatId, + before: Option, + limit: usize, + ) -> Result> { + let limit = i64::try_from(limit.min(500)).unwrap_or(500); + let mut rows = self.connection.query( + "SELECT data FROM messages WHERE chat_id=?1 AND id(0)?)?); + } + messages.reverse(); + Ok(messages) + } + + /// A cache page is reusable only when this boundary was fetched completely. + /// # Errors + /// Returns database or decoding errors. + pub async fn older_page( + &self, + chat_id: ChatId, + before_id: i32, + ) -> Result>> { + let row = self + .connection + .query( + "SELECT oldest_id FROM history_pages WHERE chat_id=?1 AND before_id=?2", + params![chat_id, before_id], + ) + .await? + .next() + .await?; + let Some(row) = row else { + return Ok(None); + }; + let oldest_id: i32 = row.get(0)?; + if oldest_id == 0 { + return Ok(Some(Vec::new())); + } + let messages = self.history(chat_id, Some(before_id), 80).await?; + if messages + .first() + .is_none_or(|message| message.id != oldest_id) + { + return Ok(None); + } + Ok(Some(messages)) + } + + /// Commit events and any covered cursor together. Replay after an interrupted + /// commit is idempotent, and snapshot writes cannot overwrite later updates. + /// # Errors + /// Returns database or encoding errors; the transaction is rolled back. + #[allow(clippy::too_many_lines)] + pub async fn apply(&mut self, events: &[NetworkEvent]) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + for event in events { + self.revision += 1; + let revision = self.revision; + match event { + NetworkEvent::PinnedMessagesLoading { + chat_id, + request_id, + } => { + self.pin_revisions.insert((*chat_id, *request_id), revision); + } + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page, + } if page.total.is_some() => { + let Some(started) = self.pin_revisions.remove(&(*chat_id, *request_id)) else { + continue; + }; + for message in &page.messages { + write_message(&transaction, message, revision, started).await?; + } + transaction.execute( + "UPDATE messages SET data=json_set(data,'$.pinned',json('false')), revision=?1 + WHERE chat_id=?2 AND data IS NOT NULL AND json_extract(data,'$.pinned')=1 + AND revision<=?3 AND (?4=0 OR id=?5", + params![revision, *chat_id, started, page.before, page.next.unwrap_or(0)], + ).await?; + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + .. + } => { + self.pin_revisions.remove(&(*chat_id, *request_id)); + } + NetworkEvent::MessagePinsChanged { + chat_id, + message_ids, + pinned, + } => { + for id in message_ids { + transaction.execute( + "UPDATE messages SET data=json_set(data,'$.pinned',json(?1)), revision=?2 + WHERE chat_id=?3 AND id=?4 AND data IS NOT NULL", + params![if *pinned { "true" } else { "false" }, revision, *chat_id, *id], + ).await?; + } + } + NetworkEvent::MessagePinsCleared { chat_id } => { + transaction.execute("UPDATE messages SET data=json_set(data,'$.pinned',json('false')), revision=?1 WHERE chat_id=?2 AND data IS NOT NULL", params![revision, *chat_id]).await?; + } + NetworkEvent::PinnedContext { + chat_id, + request_id, + messages, + .. + } => { + let Some(started) = self.pin_revisions.remove(&(*chat_id, *request_id)) else { + continue; + }; + for message in messages { + write_message(&transaction, message, revision, started).await?; + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::DialogPins(pins) => { + set_metadata(&transaction, "dialog_pins", &serde_json::to_string(pins)?) + .await?; + } + NetworkEvent::ArchiveChanged { chat_id, archived } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.membership.archived = *archived; + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::CacheAccountReset { user_id } => { + self.downloads.clear(); + transaction.execute_batch("DELETE FROM chats; DELETE FROM messages; DELETE FROM metadata; DELETE FROM attachments; DELETE FROM global_deletions; DELETE FROM history_pages;").await?; + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::Folders(folders) => { + set_metadata(&transaction, "folders", &serde_json::to_string(folders)?).await?; + } + NetworkEvent::UnreadChanged { chat_id, unread } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.unread = (*unread).max(u32::from(chat.membership.unread_mark)); + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::AccountIdentity { user_id } => { + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::Ready { user_name } => { + set_metadata(&transaction, "user_name", user_name).await?; + } + NetworkEvent::SyncCheckpoint(cursor) => { + set_metadata(&transaction, "cursor", &serde_json::to_string(cursor)?).await?; + } + NetworkEvent::DialogsLoading => self.dialog_revision = revision, + NetworkEvent::HistoryLoading { + chat_id, + request_id, + } => { + self.history_revisions + .insert((*chat_id, *request_id), revision); + } + NetworkEvent::Dialogs(chats) => { + for chat in chats { + let mut chat = chat.clone(); + if let Some((current, updated)) = load_chat(&transaction, chat.id).await? + && updated > self.dialog_revision + { + chat.last_message = current.last_message; + chat.last_activity = current.last_activity; + chat.last_message_id = current.last_message_id; + chat.unread = current.unread; + } + write_chat(&transaction, &chat, revision).await?; + } + transaction + .execute( + "DELETE FROM chats WHERE revision<=?1", + [self.dialog_revision], + ) + .await?; + } + NetworkEvent::History { + chat_id, + request_id, + messages, + } + | NetworkEvent::OlderHistory { + chat_id, + request_id, + messages, + .. + } => { + let Some(started) = self.history_revisions.remove(&(*chat_id, *request_id)) + else { + continue; + }; + if let NetworkEvent::OlderHistory { before_id, .. } = event { + transaction + .execute( + "INSERT OR REPLACE INTO history_pages VALUES(?1,?2,?3)", + params![ + *chat_id, + *before_id, + messages.first().map_or(0, |message| message.id) + ], + ) + .await?; + } + for message in messages { + write_message(&transaction, message, revision, started).await?; + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::HistoryFailed { + chat_id, + request_id, + .. + } + | NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + .. + } => { + self.history_revisions.remove(&(*chat_id, *request_id)); + } + NetworkEvent::NewMessage(message) + | NetworkEvent::CacheMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } + | NetworkEvent::MessageLoaded { message, .. } => { + if matches!(event, NetworkEvent::MessageUpdated(_)) { + let media_id = message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id); + if media_id.is_none() + || self + .downloads + .get(&(message.chat_id, message.id)) + .is_some_and(|(_, expected)| *expected != media_id) + { + self.downloads.remove(&(message.chat_id, message.id)); + } + if media_id.is_none() { + transaction + .execute( + "DELETE FROM attachments WHERE chat_id=?1 AND message_id=?2", + params![message.chat_id, message.id], + ) + .await?; + } + } + let existed = transaction + .query( + "SELECT 1 FROM messages WHERE chat_id=?1 AND id=?2", + params![message.chat_id, message.id], + ) + .await? + .next() + .await? + .is_some(); + write_message(&transaction, message, revision, revision).await?; + if let Some((mut chat, _)) = load_chat(&transaction, message.chat_id).await? { + let latest = chat.last_message_id.map_or_else( + || { + chat.last_activity + .is_none_or(|date| message.timestamp >= date) + }, + |id| message.id >= id, + ); + if matches!(event, NetworkEvent::NewMessage(_)) + && !message.outgoing + && !existed + && latest + { + chat.unread = chat.unread.saturating_add(1); + } + if latest { + chat.last_message.clone_from(&message.text); + chat.last_message_id = Some(message.id); + chat.last_activity = Some(message.timestamp); + } + write_chat(&transaction, &chat, revision).await?; + } + } + + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + for message_id in message_ids { + let mut rows = transaction.query("SELECT data FROM chats WHERE last_message_id=?1 AND ((?2 IS NULL AND id>-1000000000000) OR id=?2)", params![*message_id, *channel_id]).await?; + let mut affected = Vec::new(); + while let Some(row) = rows.next().await? { + let mut chat: Chat = serde_json::from_str(&row.get::(0)?)?; + chat.last_message.clear(); + chat.last_message_id = None; + affected.push(chat); + } + drop(rows); + for chat in affected { + write_chat(&transaction, &chat, revision).await?; + } + } + self.downloads.retain(|(chat_id, id), _| { + !(message_ids.contains(id) + && channel_id.map_or(*chat_id > -1_000_000_000_000, |channel| { + channel == *chat_id + })) + }); + for id in message_ids { + if let Some(chat_id) = channel_id { + transaction.execute( + "INSERT INTO messages VALUES(?1,?2,NULL,unixepoch(),?3) ON CONFLICT(chat_id,id) + DO UPDATE SET data=NULL,revision=excluded.revision", + params![*chat_id, *id, revision], + ).await?; + } else { + transaction + .execute( + "INSERT OR REPLACE INTO global_deletions VALUES(?1,?2)", + params![*id, revision], + ) + .await?; + transaction.execute("UPDATE messages SET data=NULL,revision=?2 WHERE id=?1 AND chat_id>-1000000000000", params![*id, revision]).await?; + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } => { + let mut rows = transaction.query("SELECT data FROM messages WHERE chat_id=?1 AND id<=?2 AND data IS NOT NULL", params![*chat_id, *max_id]).await?; + let mut messages: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + drop(rows); + for mut message in messages { + if message.outgoing { + message.delivery = Delivery::Read; + write_message(&transaction, &message, revision, revision).await?; + } + } + } + NetworkEvent::ReadMarked { chat_id } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.unread = 0; + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::AttachmentDownloadStarted { + chat_id, + message_id, + request_id, + media_id, + } => { + self.downloads + .insert((*chat_id, *message_id), (*request_id, *media_id)); + } + NetworkEvent::AttachmentDownloadFailed { + chat_id, + message_id, + request_id, + .. + } => { + if self + .downloads + .get(&(*chat_id, *message_id)) + .is_some_and(|(id, _)| *id == *request_id) + { + self.downloads.remove(&(*chat_id, *message_id)); + } + } + NetworkEvent::AttachmentDownloaded { + request_id, + chat_id, + message_id, + path, + } => { + let Some((id, media_id)) = + self.downloads.get(&(*chat_id, *message_id)).copied() + else { + continue; + }; + if id != *request_id { + continue; + } + self.downloads.remove(&(*chat_id, *message_id)); + transaction + .execute( + "INSERT OR REPLACE INTO attachments VALUES(?1,?2,?3,?4)", + params![ + *chat_id, + *message_id, + path.to_string_lossy().as_ref(), + media_id + ], + ) + .await?; + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.downloads + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.history_revisions + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.pin_revisions + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + if let Some(chat_id) = chat_id { + transaction + .execute("DELETE FROM history_pages WHERE chat_id=?1", [*chat_id]) + .await?; + transaction + .execute("DELETE FROM messages WHERE chat_id=?1", [*chat_id]) + .await?; + } else { + transaction.execute("DELETE FROM history_pages", ()).await?; + transaction.execute("DELETE FROM messages", ()).await?; + } + } + _ => {} + } + } + transaction.execute("DELETE FROM messages WHERE (chat_id,id) IN (SELECT chat_id,id FROM messages ORDER BY timestamp DESC LIMIT -1 OFFSET ?1)", [MAX_MESSAGES]).await?; + transaction.execute_batch("DELETE FROM attachments WHERE NOT EXISTS(SELECT 1 FROM messages WHERE messages.chat_id=attachments.chat_id AND messages.id=attachments.message_id AND messages.data IS NOT NULL); + DELETE FROM history_pages WHERE oldest_id>0 AND NOT EXISTS(SELECT 1 FROM messages WHERE messages.chat_id=history_pages.chat_id AND messages.id=history_pages.oldest_id);").await?; + transaction.execute("DELETE FROM history_pages WHERE rowid IN (SELECT rowid FROM history_pages ORDER BY rowid DESC LIMIT -1 OFFSET ?1)", [MAX_MESSAGES]).await?; + let oldest_in_flight = self + .history_revisions + .values() + .chain(self.pin_revisions.values()) + .copied() + .min() + .unwrap_or(self.revision); + transaction.execute("DELETE FROM global_deletions WHERE revision Result<()> { + connection + .execute( + "DELETE FROM messages WHERE chat_id=?1 AND id NOT IN + (SELECT id FROM messages WHERE chat_id=?1 ORDER BY id DESC LIMIT ?2)", + params![chat_id, MAX_CHAT_MESSAGES], + ) + .await?; + Ok(()) +} + +async fn write_message( + connection: &Connection, + message: &Message, + revision: i64, + started: i64, +) -> Result<()> { + if message.id <= 0 { + return Ok(()); + } + connection + .execute( + "DELETE FROM attachments WHERE chat_id=?1 AND message_id=?2 AND source_id IS NOT ?3", + params![ + message.chat_id, + message.id, + message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id) + ], + ) + .await?; + connection.execute( + "INSERT INTO messages(chat_id,id,data,timestamp,revision) + SELECT ?1,?2,?3,?4,?5 WHERE NOT EXISTS + (SELECT 1 FROM global_deletions WHERE id=?2 AND ?1>-1000000000000) + ON CONFLICT(chat_id,id) DO UPDATE SET data=excluded.data,timestamp=excluded.timestamp,revision=excluded.revision + WHERE messages.data IS NOT NULL AND messages.revision<=?6", + params![message.chat_id, message.id, serde_json::to_string(message)?, message.timestamp.timestamp(), revision, started], + ).await?; + Ok(()) +} + +async fn load_chat(connection: &Connection, id: ChatId) -> Result> { + let row = connection + .query("SELECT data,revision FROM chats WHERE id=?1", [id]) + .await? + .next() + .await?; + row.map(|row| Ok((serde_json::from_str(&row.get::(0)?)?, row.get(1)?))) + .transpose() +} + +async fn write_chat(connection: &Connection, chat: &Chat, revision: i64) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO chats VALUES(?1,?2,?3,?4)", + params![ + chat.id, + serde_json::to_string(chat)?, + revision, + chat.last_message_id + ], + ) + .await?; + Ok(()) +} + +async fn metadata(connection: &Connection, key: &str) -> Result> { + connection + .query("SELECT value FROM metadata WHERE key=?1", [key]) + .await? + .next() + .await? + .map(|row| row.get(0)) + .transpose() + .map_err(Into::into) +} + +async fn set_metadata(connection: &Connection, key: &str, value: &str) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO metadata VALUES(?1,?2)", + params![key, value], + ) + .await?; + Ok(()) +} + +fn prepare_file(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + bail!("message cache must be a regular file") + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path)?; + } + Err(error) => return Err(error.into()), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ChatKind; + use chrono::{TimeZone, Utc}; + + fn message(id: i32, text: &str) -> Message { + Message { + pinned: false, + id, + chat_id: 42, + sender: "Ada".to_owned(), + reply_to: None, + text: text.to_owned(), + timestamp: Utc.timestamp_opt(100 + i64::from(id), 0).unwrap(), + outgoing: false, + delivery: Delivery::Read, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + } + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn restart_keeps_live_changes_and_their_cursor_and_isolates_accounts() { + let directory = std::env::temp_dir().join(format!("termgram-cache-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("one.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = Chat { + membership: crate::folders::ChatMembership::default(), + id: 42, + title: "Group".to_owned(), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + }; + let cursor = SyncCursor { + pts: 12, + date: 123, + seq: 4, + ..SyncCursor::default() + }; + store + .apply(&[ + NetworkEvent::AccountIdentity { user_id: 7 }, + NetworkEvent::Ready { + user_name: "Ada".to_owned(), + }, + NetworkEvent::DialogsLoading, + NetworkEvent::Dialogs(vec![chat]), + NetworkEvent::ArchiveChanged { + chat_id: 42, + archived: true, + }, + NetworkEvent::DialogPins(crate::pins::DialogPins { + main: vec![7], + archive: vec![42], + }), + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessageUpdated(message(1, "edited")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + NetworkEvent::NewMessage(message(3, "live")), + NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![message(1, "old"), message(2, "deleted")], + }, + NetworkEvent::SyncCheckpoint(cursor.clone()), + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + let messages = store.history(42, None, 80).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|message| message.text.as_str()) + .collect::>(), + vec!["edited", "live"] + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + assert_eq!(store.account_id().await.unwrap(), Some(7)); + assert!(store.snapshot().await.unwrap().1[0].membership.archived); + assert_eq!(store.dialog_pins().await.unwrap().archive, vec![42]); + assert_eq!(store.snapshot().await.unwrap().0.as_deref(), Some("Ada")); + let other = Store::open(&directory.join("two.sqlite3")).await.unwrap(); + assert!(other.history(42, None, 80).await.unwrap().is_empty()); + assert!(other.snapshot().await.unwrap().1.is_empty()); + drop(other); + + // A failed batch must roll back both its content and its checkpoint. + store.connection.execute_batch("CREATE TRIGGER reject_message BEFORE INSERT ON messages WHEN NEW.id=9 BEGIN SELECT RAISE(ABORT,'simulated write failure'); END;").await.unwrap(); + assert!( + store + .apply(&[ + NetworkEvent::SyncCheckpoint(SyncCursor { + pts: 99, + ..cursor.clone() + }), + NetworkEvent::NewMessage(message(9, "uncommitted")) + ]) + .await + .is_err() + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + store + .apply(&[NetworkEvent::CacheAccountReset { user_id: 8 }]) + .await + .unwrap(); + assert!(store.history(42, None, 80).await.unwrap().is_empty()); + assert!(store.snapshot().await.unwrap().1.is_empty()); + assert_eq!(store.cursor().await.unwrap(), SyncCursor::default()); + assert_eq!( + store.dialog_pins().await.unwrap(), + crate::pins::DialogPins::default() + ); + drop(store); + std::fs::remove_dir_all(directory).unwrap(); + } + #[tokio::test] + async fn cached_paging_requires_a_complete_boundary_and_keeps_tombstones() { + let directory = + std::env::temp_dir().join(format!("termgram-paging-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let mut store = Store::open(&directory.join("cache.sqlite3")).await.unwrap(); + store + .apply(&[ + NetworkEvent::NewMessage(message(3, "live")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + ]) + .await + .unwrap(); + assert!( + store.older_page(42, 4).await.unwrap().is_none(), + "isolated cached messages are not a complete history page" + ); + store + .apply(&[ + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::OlderHistory { + chat_id: 42, + request_id: 2, + before_id: 4, + messages: vec![ + message(1, "edited"), + message(2, "deleted"), + message(3, "live"), + ], + }, + ]) + .await + .unwrap(); + assert_eq!(store.older_page(42, 4).await.unwrap().unwrap().len(), 2); + + drop(store); + std::fs::remove_dir_all(directory).unwrap(); + } + #[tokio::test] + async fn attachment_mapping_survives_restart_but_not_media_edits() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("cache.sqlite3"); + let path = directory.path().join("file.txt"); + std::fs::write(&path, "file").unwrap(); + let mut store = Store::open(&database).await.unwrap(); + let mut media = message(1, "file"); + media.attachment = Some(crate::model::Attachment { + source_id: Some(55), + kind: crate::model::AttachmentKind::File, + file_name: Some("file.txt".to_owned()), + mime_type: None, + size: Some(4), + fallback_emoji: None, + }); + store + .apply(&[ + NetworkEvent::NewMessage(media.clone()), + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 1, + media_id: Some(55), + }, + NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 1, + path: path.clone(), + }, + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&database).await.unwrap(); + assert_eq!( + store.attachment(42, 1, Some(55)).await.unwrap(), + Some(path.clone()) + ); + assert!(store.attachment(42, 1, Some(56)).await.unwrap().is_none()); + std::fs::remove_file(&path).unwrap(); + assert!(store.attachment(42, 1, Some(55)).await.unwrap().is_none()); + std::fs::write(&path, "file").unwrap(); + media.attachment.as_mut().unwrap().source_id = Some(56); + store + .apply(&[ + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 2, + media_id: Some(55), + }, + NetworkEvent::MessageUpdated(media), + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 3, + media_id: Some(56), + }, + NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 2, + path: path.clone(), + }, + ]) + .await + .unwrap(); + assert!(store.attachment(42, 1, Some(55)).await.unwrap().is_none()); + assert!(store.attachment(42, 1, Some(56)).await.unwrap().is_none()); + store + .apply(&[NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 3, + path: path.clone(), + }]) + .await + .unwrap(); + assert_eq!(store.attachment(42, 1, Some(56)).await.unwrap(), Some(path)); + } + + #[tokio::test] + async fn account_cache_has_one_owner_until_transfers_release_the_lock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite3"); + let store = Store::open(&path).await.unwrap(); + let transfer_owner = store.owner(); + assert!(Store::open(&path).await.is_err()); + drop(store); + assert!(Store::open(&path).await.is_err()); + drop(transfer_owner); + assert!(Store::open(&path).await.is_ok()); + } + + #[tokio::test] + async fn pinned_pages_persist_without_undoing_live_unpins_or_deletions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pins.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let mut pin = message(4, "pinned"); + pin.pinned = true; + let mut removed = message(5, "deleted"); + removed.pinned = true; + store + .apply(&[ + NetworkEvent::CacheMessage(pin.clone()), + NetworkEvent::CacheMessage(removed.clone()), + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessagePinsChanged { + chat_id: 42, + message_ids: vec![4], + pinned: false, + }, + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![5], + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 1, + page: crate::pins::MessagePage { + messages: vec![removed, pin.clone()], + total: Some(2), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + assert!( + store + .pinned_messages(42, 0) + .await + .unwrap() + .messages + .is_empty() + ); + store + .apply(&[ + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 2, + page: crate::pins::MessagePage { + messages: vec![pin], + total: Some(1), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + assert_eq!( + store.pinned_messages(42, 0).await.unwrap().messages[0].id, + 4 + ); + store + .apply(&[NetworkEvent::MessagePinsCleared { chat_id: 42 }]) + .await + .unwrap(); + assert!( + store + .pinned_messages(42, 0) + .await + .unwrap() + .messages + .is_empty() + ); + assert_eq!(store.history(42, None, 20).await.unwrap().len(), 1); + } + #[tokio::test] + async fn invalidated_pin_reads_cannot_repopulate_the_cache() { + for scope in [Some(42), None] { + let directory = tempfile::tempdir().unwrap(); + let mut store = Store::open(&directory.path().join("pins.sqlite3")) + .await + .unwrap(); + let mut pin = message(4, "obsolete pin"); + pin.pinned = true; + let mut other = pin.clone(); + other.chat_id = 99; + store + .apply(&[ + NetworkEvent::CacheMessage(pin.clone()), + NetworkEvent::CacheMessage(other), + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::CacheInvalidated { chat_id: scope }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 1, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, + }, + NetworkEvent::PinnedContext { + chat_id: 42, + message_id: 4, + request_id: 2, + messages: vec![pin.clone()], + }, + ]) + .await + .unwrap(); + assert!(store.history(42, None, 20).await.unwrap().is_empty()); + assert_eq!( + store.history(99, None, 20).await.unwrap().is_empty(), + scope.is_none() + ); + pin.id = 6; + store + .apply(&[ + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 3, + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 3, + page: crate::pins::MessagePage { + messages: vec![pin], + total: Some(1), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + let pins = store.pinned_messages(42, 0).await.unwrap(); + assert_eq!(pins.messages.len(), 1); + assert_eq!(pins.messages[0].id, 6); + } + } + + #[tokio::test] + async fn cached_dialogs_count_new_messages_once_and_clear_deleted_previews() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = Chat { + id: 42, + title: "Group".to_owned(), + kind: ChatKind::Group, + unread: 0, + membership: crate::folders::ChatMembership::default(), + last_message: "first".to_owned(), + last_message_id: Some(1), + last_activity: Some(message(1, "first").timestamp), + }; + store + .apply(&[ + NetworkEvent::DialogsLoading, + NetworkEvent::Dialogs(vec![chat]), + NetworkEvent::NewMessage(message(2, "second")), + NetworkEvent::NewMessage(message(2, "second")), + NetworkEvent::MessageUpdated(message(1, "edited first")), + ]) + .await + .unwrap(); + let chats = store.snapshot().await.unwrap().1; + assert_eq!(chats[0].unread, 1); + assert_eq!(chats[0].last_message_id, Some(2)); + assert_eq!(chats[0].last_message, "second"); + store + .apply(&[ + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + NetworkEvent::UnreadChanged { + chat_id: 42, + unread: 0, + }, + ]) + .await + .unwrap(); + drop(store); + let store = Store::open(&path).await.unwrap(); + let chats = store.snapshot().await.unwrap().1; + assert!(chats[0].last_message.is_empty()); + assert_eq!(chats[0].unread, 0); + assert_eq!(chats[0].last_message_id, None); + } +} diff --git a/src/config.rs b/src/config.rs index 9456c87..e271443 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,9 +48,9 @@ impl ReleaseChannel { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum DownloadBehavior { - /// Download to Termgram's private temporary directory, but never ask the - /// operating system to reveal the file. - TempOnly, + /// Enter keeps the file in Termgram's managed cache. The separate + /// explicit reveal action remains available. + CacheOnly, /// A second explicit activation reveals the containing folder or selects /// the file; downloaded content is never executed directly. #[default] @@ -61,7 +61,7 @@ impl DownloadBehavior { #[must_use] pub const fn label(self) -> &'static str { match self { - Self::TempOnly => "Temp only", + Self::CacheOnly => "Keep in cache", Self::RevealOnActivation => "Reveal on activation", } } @@ -69,14 +69,14 @@ impl DownloadBehavior { #[must_use] pub const fn toggled(self) -> Self { match self { - Self::TempOnly => Self::RevealOnActivation, - Self::RevealOnActivation => Self::TempOnly, + Self::CacheOnly => Self::RevealOnActivation, + Self::RevealOnActivation => Self::CacheOnly, } } const fn persisted(self) -> &'static str { match self { - Self::TempOnly => "temp_only", + Self::CacheOnly => "cache_only", Self::RevealOnActivation => "reveal_on_activation", } } @@ -183,73 +183,7 @@ impl Settings { /// Returns an error if the directory is unsafe, the destination is not a /// regular file, or an atomic write cannot be completed. pub fn save_to(self, path: &Path) -> Result<()> { - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let parent_created = !parent.exists(); - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - let parent_metadata = fs::symlink_metadata(parent) - .with_context(|| format!("failed to inspect {}", parent.display()))?; - if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { - bail!("settings directory must be a real directory"); - } - protect_settings_directory(parent, parent_created)?; - - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - bail!("refusing to replace settings through a symbolic link"); - } - Ok(metadata) if !metadata.is_file() => { - bail!("settings path is not a regular file"); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error).with_context(|| format!("failed to inspect {}", path.display())); - } - } - - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(SETTINGS_FILE_NAME); - let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); - let result = (|| -> Result<()> { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&temporary) - .with_context(|| format!("failed to create {}", temporary.display()))?; - file.write_all(self.serialize().as_bytes()) - .with_context(|| format!("failed to write {}", temporary.display()))?; - file.sync_all() - .with_context(|| format!("failed to sync {}", temporary.display()))?; - drop(file); - replace_settings_file(&temporary, path, nonce)?; - protect_settings_file(path)?; - #[cfg(unix)] - OpenOptions::new() - .read(true) - .open(parent) - .and_then(|directory| directory.sync_all()) - .with_context(|| format!("failed to sync {}", parent.display()))?; - Ok(()) - })(); - if result.is_err() { - drop(fs::remove_file(&temporary)); - } - result + write_preferences(path, self.serialize().as_bytes()) } fn serialize(self) -> String { @@ -265,6 +199,93 @@ impl Settings { } } +/// Read bounded JSON preferences, using defaults only when the file is absent. +/// +/// # Errors +/// Returns read or format errors without replacing an existing file. +pub(crate) fn read_preferences(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()), + Err(error) => return Err(error.into()), + }; + if text.len() > 1024 * 1024 { + bail!("preferences exceed 1 MiB"); + } + Ok(serde_json::from_str(&text)?) +} + +/// Shared atomic writer for managed preferences, with the same platform cleanup +/// and path protections as settings.conf. +pub(crate) fn write_preferences(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent_created = !parent.exists(); + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let parent_metadata = fs::symlink_metadata(parent) + .with_context(|| format!("failed to inspect {}", parent.display()))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + bail!("settings directory must be a real directory"); + } + protect_settings_directory(parent, parent_created)?; + + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + bail!("refusing to replace settings through a symbolic link"); + } + Ok(metadata) if !metadata.is_file() => { + bail!("settings path is not a regular file"); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())); + } + } + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(SETTINGS_FILE_NAME); + let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .with_context(|| format!("failed to create {}", temporary.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", temporary.display()))?; + drop(file); + replace_settings_file(&temporary, path, nonce)?; + protect_settings_file(path)?; + #[cfg(unix)] + OpenOptions::new() + .read(true) + .open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync {}", parent.display()))?; + Ok(()) + })(); + if result.is_err() { + drop(fs::remove_file(&temporary)); + } + result +} + fn parse_settings(text: &str) -> Result { let mut settings = Settings::default(); for (index, line) in text.lines().enumerate() { @@ -294,7 +315,7 @@ fn parse_settings(text: &str) -> Result { } "download_behavior" => { settings.download_behavior = match value.trim() { - "temp_only" => DownloadBehavior::TempOnly, + "cache_only" | "temp_only" => DownloadBehavior::CacheOnly, "reveal_on_activation" => DownloadBehavior::RevealOnActivation, _ => bail!("download_behavior has an unsupported value"), }; @@ -691,7 +712,7 @@ mod tests { let second = Settings { automatic_update_checks: false, release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, + download_behavior: DownloadBehavior::CacheOnly, show_message_ids: true, active_account: 2, account_count: 3, diff --git a/src/event.rs b/src/event.rs index 12a6276..eb9410a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -67,6 +67,11 @@ pub enum TelegramCommand { SubmitPassword(String), /// Abandon the current login token and request a fresh phone/code flow. RestartAuth, + LoadOlder { + chat_id: ChatId, + request_id: u64, + before_id: i32, + }, LoadHistory { chat_id: ChatId, request_id: u64, @@ -100,9 +105,11 @@ pub enum TelegramCommand { /// sent as a reply. reply_to: Option, }, - /// Lazily download media from a known message into Termgram's temporary - /// download directory. + /// Lazily download media from a known message into Termgram's managed + /// media cache. DownloadAttachment { + request_id: u64, + media_id: Option, chat_id: ChatId, message_id: i32, }, @@ -129,6 +136,42 @@ pub enum TelegramCommand { chat_id: ChatId, }, RefreshDialogs, + RefreshDialogPins, + LoadPinnedMessages { + chat_id: ChatId, + before: i32, + request_id: u64, + }, + LoadPinnedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + ChangeMessagePin { + chat_id: ChatId, + message_id: i32, + action: crate::pins::MessageAction, + request_id: u64, + }, + SetArchived { + chat_id: ChatId, + archived: bool, + request_id: u64, + }, + ChangeDialogPin { + chat_id: ChatId, + scope: crate::pins::DialogScope, + action: crate::pins::DialogAction, + request_id: u64, + }, + RefreshFolders, + SearchCached(crate::search::Request), + CancelSearch, + LoadCachedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, /// Select a different local session slot. The runtime intercepts this /// command and replaces the single active Telegram worker. SwitchAccount { @@ -137,10 +180,168 @@ pub enum TelegramCommand { Shutdown, } +impl TelegramCommand { + /// Return the matching failure event so pending UI operations always settle. + #[must_use] + #[allow(clippy::too_many_lines)] + pub fn failure(self, error: String) -> Option { + let event = match self { + Self::LoadPinnedMessages { + chat_id, + request_id, + .. + } + | Self::LoadPinnedContext { + chat_id, + request_id, + .. + } => NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + error, + }, + Self::ChangeMessagePin { + chat_id, + request_id, + .. + } => NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error: Some(error), + }, + Self::ChangeDialogPin { request_id, .. } | Self::SetArchived { request_id, .. } => { + NetworkEvent::DialogPinFinished { + request_id, + error: Some(error), + } + } + Self::LoadOlder { + chat_id, + request_id, + .. + } => NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::SendMessage { + chat_id, + local_id, + text, + reply_to, + } => NetworkEvent::SendFailed { + chat_id, + local_id, + text, + reply_to, + error, + }, + TelegramCommand::SendAttachment { + chat_id, + local_id, + path, + caption, + as_photo, + reply_to, + } => NetworkEvent::AttachmentSendFailed { + chat_id, + local_id, + path, + caption, + as_photo, + reply_to, + error, + }, + TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::AttachmentDownloadFailed { + request_id, + chat_id, + message_id, + error, + }, + TelegramCommand::DownloadPreview { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::PreviewDownloadFailed { + chat_id, + message_id, + request_id, + error, + }, + TelegramCommand::ResolveTelegramLink { url } => NetworkEvent::LinkFailed { url, error }, + TelegramCommand::ActivateButton { + chat_id, + message_id, + button_index: _, + } => NetworkEvent::ButtonFailed { + chat_id, + message_id, + error, + }, + TelegramCommand::LoadHistory { + chat_id, + request_id, + } => NetworkEvent::HistoryFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::LoadMessage { + chat_id, + source_message_id: _, + message_id, + request_id, + } => NetworkEvent::MessageLoadFailed { + chat_id, + message_id, + request_id, + error, + }, + TelegramCommand::MarkRead { chat_id } => { + NetworkEvent::ReadMarkFailed { chat_id, error } + } + TelegramCommand::SearchCached(request) => NetworkEvent::SearchFailed { + request_id: request.id, + error, + }, + TelegramCommand::LoadCachedContext { request_id, .. } => { + NetworkEvent::SearchFailed { request_id, error } + } + TelegramCommand::RefreshFolders | Self::RefreshDialogPins => NetworkEvent::Error(error), + TelegramCommand::RefreshDialogs => NetworkEvent::DialogsFailed(error), + TelegramCommand::CancelSearch + | TelegramCommand::SwitchAccount { .. } + | TelegramCommand::Shutdown => return None, + TelegramCommand::StartQrAuth + | TelegramCommand::SubmitPhone(_) + | TelegramCommand::SubmitCode(_) + | TelegramCommand::SubmitPassword(_) + | TelegramCommand::RestartAuth => NetworkEvent::Fatal(error), + }; + Some(event) + } +} + impl fmt::Debug for TelegramCommand { #[allow(clippy::too_many_lines)] fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::LoadOlder { + chat_id, + request_id, + before_id, + } => formatter + .debug_struct("LoadOlder") + .field("chat_id", chat_id) + .field("request_id", request_id) + .field("before_id", before_id) + .finish(), Self::StartQrAuth => formatter.write_str("StartQrAuth"), Self::SubmitPhone(_) => formatter .debug_tuple("SubmitPhone") @@ -206,6 +407,7 @@ impl fmt::Debug for TelegramCommand { Self::DownloadAttachment { chat_id, message_id, + .. } => formatter .debug_struct("DownloadAttachment") .field("chat_id", chat_id) @@ -241,7 +443,78 @@ impl fmt::Debug for TelegramCommand { .debug_struct("MarkRead") .field("chat_id", chat_id) .finish(), + Self::SearchCached(request) => formatter + .debug_struct("SearchCached") + .field("request_id", &request.id) + .finish_non_exhaustive(), + Self::LoadCachedContext { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadCachedContext") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::CancelSearch => formatter.write_str("CancelSearch"), + Self::RefreshFolders => formatter.write_str("RefreshFolders"), Self::RefreshDialogs => formatter.write_str("RefreshDialogs"), + Self::RefreshDialogPins => formatter.write_str("RefreshDialogPins"), + Self::LoadPinnedMessages { + chat_id, + before, + request_id, + } => formatter + .debug_struct("LoadPinnedMessages") + .field("chat_id", chat_id) + .field("before", before) + .field("request_id", request_id) + .finish(), + Self::LoadPinnedContext { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadPinnedContext") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::ChangeMessagePin { + chat_id, + message_id, + action, + request_id, + } => formatter + .debug_struct("ChangeMessagePin") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("action", action) + .field("request_id", request_id) + .finish(), + Self::SetArchived { + chat_id, + archived, + request_id, + } => formatter + .debug_struct("SetArchived") + .field("chat_id", chat_id) + .field("archived", archived) + .field("request_id", request_id) + .finish(), + Self::ChangeDialogPin { + chat_id, + scope, + action, + request_id, + } => formatter + .debug_struct("ChangeDialogPin") + .field("chat_id", chat_id) + .field("scope", scope) + .field("action", action) + .field("request_id", request_id) + .finish(), Self::SwitchAccount { account } => formatter .debug_struct("SwitchAccount") .field("account", account) @@ -254,10 +527,113 @@ impl fmt::Debug for TelegramCommand { /// SDK-independent updates sent from the Telegram worker to the application. #[derive(Clone, Debug, Eq, PartialEq)] pub enum NetworkEvent { + PinnedMessagesLoading { + chat_id: ChatId, + request_id: u64, + }, + PinnedMessages { + chat_id: ChatId, + request_id: u64, + page: crate::pins::MessagePage, + }, + PinnedMessagesFailed { + chat_id: ChatId, + request_id: u64, + error: String, + }, + PinnedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + messages: Vec, + }, + MessagePinsChanged { + chat_id: ChatId, + message_ids: Vec, + pinned: bool, + }, + MessagePinsCleared { + chat_id: ChatId, + }, + MessagePinFinished { + chat_id: ChatId, + request_id: u64, + error: Option, + }, + DialogPins(crate::pins::DialogPins), + ArchiveChanged { + chat_id: ChatId, + archived: bool, + }, + DialogPinFinished { + request_id: u64, + error: Option, + }, + AttachmentDownloadStarted { + chat_id: ChatId, + message_id: i32, + request_id: u64, + media_id: Option, + }, + CachedContext { + request_id: u64, + chat_id: ChatId, + message_id: i32, + messages: Vec, + }, + SearchResults { + request_id: u64, + page: crate::search::Page, + }, + SearchFailed { + request_id: u64, + error: String, + }, + Folders(Vec), + UnreadChanged { + chat_id: ChatId, + unread: u32, + }, + OlderHistory { + chat_id: ChatId, + request_id: u64, + before_id: i32, + messages: Vec, + }, + OlderHistoryFailed { + chat_id: ChatId, + request_id: u64, + error: String, + }, + CachedSnapshot { + user_name: Option, + chats: Vec, + }, + CachedHistory { + chat_id: ChatId, + request_id: u64, + messages: Vec, + }, + HistoryLoading { + chat_id: ChatId, + request_id: u64, + }, + SyncCheckpoint(crate::cache::SyncCursor), + CacheAccountReset { + user_id: i64, + }, + AccountIdentity { + user_id: i64, + }, + CacheMessage(Message), + CacheInvalidated { + chat_id: Option, + }, Auth(AuthPrompt), Ready { user_name: String, }, + DialogsLoading, Dialogs(Vec), History { chat_id: ChatId, @@ -342,11 +718,13 @@ pub enum NetworkEvent { message_ids: Vec, }, AttachmentDownloaded { + request_id: u64, chat_id: ChatId, message_id: i32, path: PathBuf, }, AttachmentDownloadFailed { + request_id: u64, chat_id: ChatId, message_id: i32, error: String, diff --git a/src/folders.rs b/src/folders.rs new file mode 100644 index 0000000..f00adc8 --- /dev/null +++ b/src/folders.rs @@ -0,0 +1,137 @@ +//! Native Telegram dialog filters, independent of SDK and terminal types. + +use crate::model::{Chat, ChatId, ChatKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] +pub struct ChatMembership { + pub contact: bool, + pub bot: bool, + pub archived: bool, + pub unread_mark: bool, + pub mentions: bool, + pub mute_until: i64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] +pub struct Folder { + pub id: i32, + pub title: String, + pub pinned: Vec, + pub include: Vec, + pub exclude: Vec, + pub contacts: bool, + pub non_contacts: bool, + pub bots: bool, + pub groups: bool, + pub broadcasts: bool, + pub exclude_muted: bool, + pub exclude_read: bool, + pub exclude_archived: bool, +} + +impl Folder { + #[must_use] + pub fn all() -> Self { + Self { + title: "All chats".to_owned(), + ..Self::default() + } + } + + #[must_use] + pub fn archive() -> Self { + Self { + id: 1, + title: "Archive".to_owned(), + ..Self::default() + } + } + + #[must_use] + pub fn contains(&self, chat: &Chat, now: i64) -> bool { + if self.id == 0 { + return !chat.membership.archived; + } + if self.id == 1 { + return chat.membership.archived; + } + if self.exclude.contains(&chat.id) { + return false; + } + if self.include.contains(&chat.id) || self.pinned.contains(&chat.id) { + return true; + } + let membership = &chat.membership; + let kind = match chat.kind { + ChatKind::Direct if membership.bot => self.bots, + ChatKind::Direct if membership.contact => self.contacts, + ChatKind::Direct => self.non_contacts, + ChatKind::Group => self.groups, + ChatKind::Channel => self.broadcasts, + }; + kind && (!self.exclude_archived || !membership.archived) + && (!self.exclude_read + || chat.unread > 0 + || membership.mentions + || membership.unread_mark) + && (!self.exclude_muted + || membership.mute_until <= now + || (membership.mentions && !membership.archived)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn explicit_membership_wins_and_muted_mentions_follow_desktop_rules() { + let mut chat = Chat { + id: 1, + title: String::new(), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + membership: ChatMembership { + mute_until: 500, + ..ChatMembership::default() + }, + }; + let mut folder = Folder { + id: 2, + groups: true, + exclude_muted: true, + exclude_read: true, + ..Folder::default() + }; + assert!(!folder.contains(&chat, 100)); + chat.membership.mentions = true; + assert!(folder.contains(&chat, 100)); + chat.membership.archived = true; + assert!(!folder.contains(&chat, 100)); + folder.include.push(1); + assert!(folder.contains(&chat, 100)); + folder.exclude.push(1); + assert!(!folder.contains(&chat, 100)); + folder.exclude.clear(); + folder.include.clear(); + folder.exclude_read = false; + assert!(folder.contains(&chat, 600)); + folder.exclude_archived = true; + assert!(!folder.contains(&chat, 600)); + chat.kind = ChatKind::Direct; + chat.membership.bot = true; + chat.membership.contact = true; + folder.contacts = true; + folder.exclude_archived = false; + assert!(!folder.contains(&chat, 600)); + folder.bots = true; + assert!(folder.contains(&chat, 600)); + } +} diff --git a/src/input.rs b/src/input.rs index af542ab..a084a8d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -2,7 +2,6 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers}; const MAX_INPUT_BYTES: usize = 16 * 1024; @@ -36,64 +35,6 @@ pub enum KeyAction { AddAccount, } -/// Convert a Yazi key event into an action understood by the app. -/// -/// Key releases and modified characters (except Ctrl-C) are deliberately -/// ignored. This prevents shortcuts such as Ctrl-Q from unexpectedly entering -/// text or triggering the plain `q` navigation shortcut. -#[must_use] -pub fn key_action(event: &KeyEvent) -> Option { - if event.kind == KeyEventKind::Release { - return None; - } - - if event.modifiers.contains(Modifiers::CONTROL) - && matches!(event.code, KeyCode::Char('c' | 'C')) - { - return Some(KeyAction::Quit); - } - - if event.modifiers.contains(Modifiers::CONTROL) { - return match event.code { - KeyCode::Char('j' | 'J') => Some(KeyAction::Newline), - KeyCode::Char('a' | 'A') => Some(KeyAction::Home), - KeyCode::Char('e' | 'E') => Some(KeyAction::End), - KeyCode::Char('u' | 'U') => Some(KeyAction::Clear), - KeyCode::Char('w' | 'W') => Some(KeyAction::DeleteWord), - KeyCode::Char('l' | 'L') => Some(KeyAction::Redraw), - _ => None, - }; - } - - let unsupported_modifiers = - Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER | Modifiers::HYPER | Modifiers::META; - if event.modifiers.intersects(unsupported_modifiers) { - return None; - } - - match event.shifted_code() { - KeyCode::Char(character) => Some(KeyAction::Character(character)), - KeyCode::Enter if event.modifiers.contains(Modifiers::SHIFT) => Some(KeyAction::Newline), - KeyCode::Enter => Some(KeyAction::Enter), - KeyCode::Escape => Some(KeyAction::Escape), - KeyCode::Tab if event.modifiers.contains(Modifiers::SHIFT) => Some(KeyAction::BackTab), - KeyCode::Tab => Some(KeyAction::Tab), - KeyCode::Backspace => Some(KeyAction::Backspace), - KeyCode::Delete => Some(KeyAction::Delete), - KeyCode::Left => Some(KeyAction::Left), - KeyCode::Right => Some(KeyAction::Right), - KeyCode::Up => Some(KeyAction::Up), - KeyCode::Down => Some(KeyAction::Down), - KeyCode::PageUp => Some(KeyAction::PageUp), - KeyCode::PageDown => Some(KeyAction::PageDown), - KeyCode::Home => Some(KeyAction::Home), - KeyCode::End => Some(KeyAction::End), - KeyCode::Fn(2) => Some(KeyAction::NextAccount), - KeyCode::Fn(3) => Some(KeyAction::AddAccount), - _ => None, - } -} - /// A UTF-8 text buffer whose cursor always rests on a grapheme boundary. /// /// `cursor()` is a byte offset, which makes slicing the returned `value()` @@ -310,68 +251,7 @@ fn boundary_at_or_after(value: &str, offset: usize) -> usize { #[cfg(test)] mod tests { - use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, Modifiers}; - - use super::{KeyAction, TextInput, key_action}; - - #[test] - fn ctrl_c_is_quit_and_plain_q_remains_a_character() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('c'), Modifiers::CONTROL)), - Some(KeyAction::Quit) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('q'), Modifiers::empty())), - Some(KeyAction::Character('q')) - ); - } - - #[test] - fn function_keys_select_account_actions() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Fn(2), Modifiers::empty())), - Some(KeyAction::NextAccount) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Fn(3), Modifiers::empty())), - Some(KeyAction::AddAccount) - ); - } - - #[test] - fn enhanced_keys_preserve_shifted_shortcuts_and_newlines() { - let key = KeyEvent { - code: KeyCode::Char('/'), - shifted: Some('?'), - modifiers: Modifiers::SHIFT, - ..KeyEvent::default() - }; - assert_eq!(key_action(&key), Some(KeyAction::Character('?'))); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Tab, Modifiers::SHIFT)), - Some(KeyAction::BackTab) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Enter, Modifiers::SHIFT)), - Some(KeyAction::Newline) - ); - } - - #[test] - fn releases_and_other_control_combinations_are_ignored() { - let release = KeyEvent { - code: KeyCode::Enter, - modifiers: Modifiers::empty(), - kind: KeyEventKind::Release, - state: KeyEventState::empty(), - ..KeyEvent::default() - }; - assert_eq!(key_action(&release), None); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('x'), Modifiers::CONTROL)), - None - ); - } + use super::TextInput; #[test] fn edits_combining_graphemes_as_one_character() { @@ -424,18 +304,6 @@ mod tests { assert_eq!(input.cursor(), input.value().len()); } - #[test] - fn control_bindings_are_normalized() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('j'), Modifiers::CONTROL)), - Some(KeyAction::Newline) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('w'), Modifiers::CONTROL)), - Some(KeyAction::DeleteWord) - ); - } - #[test] fn deletes_a_unicode_word_and_preceding_space() { let mut input = TextInput::from_value("hello 世界 "); diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..0155af7 --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,741 @@ +//! Declarative Lua configuration and contextual chords. Yazi owns terminal key +//! parsing/normalization; this module only maps those keys to Termgram actions. + +use anyhow::{Context as _, Result, bail}; +use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, VmState}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + path::Path, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; +use yazi_config::keymap::Key; +use yazi_term::event::{KeyEvent, KeyEventKind}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Context { + Global, + Chats, + Conversation, + Compose, + Input, + Overlay, + Preview, + Search, + Pins, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BindingSpec { + context: Context, + on: Vec, + run: String, + #[serde(default = "default_count")] + count: usize, + #[serde(default)] + desc: Option, +} + +const fn default_count() -> usize { + 1 +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Configuration { + keymap: Vec, + chats: BTreeMap, + ghost_text: Option, + nerd_font: bool, + colors: crate::appearance::Colors, +} + +#[derive(Clone, Debug)] +struct Binding { + context: Context, + keys: Vec, + run: String, + count: usize, + description: String, +} + +#[derive(Clone, Debug)] +pub struct Keymap { + bindings: Vec, + pub chats: BTreeMap, + pub ghost_text: String, + pub nerd_font: bool, + pub colors: crate::appearance::Colors, + pending: Vec, + count: usize, + context: Option, + last_key: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Resolution { + Action { run: String, count: usize }, + Pending(String), + Unbound, +} + +const ACTIONS: &[&str] = &[ + "quit", + "help", + "settings", + "accounts", + "next_account", + "add_account", + "open", + "preview", + "compose", + "send", + "newline", + "cancel", + "focus", + "up", + "down", + "message_up", + "message_down", + "page_up", + "page_down", + "oldest", + "latest", + "filter", + "refresh", + "reply", + "reply_target", + "open_link", + "next_action", + "previous_action", + "reveal", + "redraw", + "home", + "end", + "left", + "right", + "backspace", + "delete", + "clear", + "delete_word", + "noop", + "chat_color", + "folder_color", + "search", + "search_scope", + "search_more", + "search_previous", + "search_query", + "chat_info", + "folder_next", + "folder_previous", + "pin", + "pin_up", + "pin_down", + "archive", + "pins", + "pins_more", + "pins_previous", + "unpin_all", +]; + +impl Default for Keymap { + #[allow(clippy::too_many_lines)] + fn default() -> Self { + let mut result = Self { + bindings: Vec::new(), + chats: BTreeMap::new(), + colors: crate::appearance::Colors::default(), + ghost_text: "{send} to send".to_owned(), + nerd_font: false, + pending: Vec::new(), + count: 0, + context: None, + last_key: None, + }; + for (context, definitions) in [ + ( + Context::Global, + &[ + ("", "quit"), + ("", "redraw"), + ("", "next_account"), + ("", "add_account"), + ][..], + ), + ( + Context::Chats, + &[ + ("j", "down"), + ("k", "up"), + ("", "down"), + ("", "up"), + ("", "open"), + ("", "open"), + ("", "focus"), + ("", "focus"), + ("i", "compose"), + ("c", "chat_color"), + ("p", "pin"), + ("e", "archive"), + ("", "pin_up"), + ("", "pin_down"), + ("C", "folder_color"), + ("/", "filter"), + ("", "search"), + ("]", "folder_next"), + ("[", "folder_previous"), + ("q", "quit"), + ("?", "help"), + ("s", "settings"), + ("a", "accounts"), + ("", "page_up"), + ("", "page_down"), + ("G", "latest"), + ("g g", "oldest"), + ("g i", "chat_info"), + ("", "refresh"), + ][..], + ), + ( + Context::Conversation, + &[ + ("p", "pin"), + ("P", "pins"), + ("c", "chat_color"), + ("/", "search"), + ("j", "message_down"), + ("k", "message_up"), + ("[", "message_up"), + ("]", "message_down"), + ("", "down"), + ("", "up"), + ("", "page_up"), + ("", "page_down"), + ("G", "latest"), + ("", "latest"), + ("g g", "oldest"), + ("g i", "chat_info"), + ("", "oldest"), + ("i", "compose"), + ("", "open"), + ("", "cancel"), + ("", "cancel"), + ("", "focus"), + ("", "focus"), + ("q", "quit"), + ("?", "help"), + ("s", "settings"), + ("a", "accounts"), + ("o", "preview"), + ("g n", "next_action"), + ("g p", "previous_action"), + ("O", "reveal"), + ("R", "reply"), + ("r", "reply_target"), + ("l", "open_link"), + ("", "refresh"), + ][..], + ), + ( + Context::Preview, + &[ + ("", "cancel"), + ("q", "cancel"), + ("i", "reply"), + ("O", "reveal"), + ("o", "preview"), + ][..], + ), + ( + Context::Compose, + &[ + ("", "send"), + ("", "newline"), + ("", "newline"), + ("", "cancel"), + ][..], + ), + ( + Context::Input, + &[ + ("", "open"), + ("", "cancel"), + ("", "focus"), + ("", "focus"), + ("", "up"), + ("", "down"), + ][..], + ), + ( + Context::Overlay, + &[ + ("", "open"), + ("", "open"), + ("", "cancel"), + ("?", "cancel"), + ("j", "down"), + ("k", "up"), + ("", "up"), + ("", "down"), + ][..], + ), + ( + Context::Search, + &[ + ("", "open"), + ("", "cancel"), + ("", "search_scope"), + ("", "search_more"), + ("", "search_previous"), + ("", "search_query"), + ("", "up"), + ("", "down"), + ("", "page_up"), + ("", "page_down"), + ][..], + ), + ( + Context::Pins, + &[ + ("", "open"), + ("", "cancel"), + ("?", "cancel"), + ("j", "down"), + ("k", "up"), + ("", "down"), + ("", "up"), + ("", "page_up"), + ("", "page_down"), + ("", "pins_more"), + ("", "pins_previous"), + ("", "refresh"), + ("p", "pin"), + ("U", "unpin_all"), + ][..], + ), + ] { + for &(keys, run) in definitions { + result + .insert(BindingSpec { + context, + on: keys.split(' ').map(str::to_owned).collect(), + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid builtin binding"); + } + } + for context in [Context::Compose, Context::Input, Context::Search] { + for (key, run) in [ + ("", "home"), + ("", "end"), + ("", "clear"), + ("", "delete_word"), + ("", "home"), + ("", "end"), + ("", "left"), + ("", "right"), + ("", "backspace"), + ("", "delete"), + ] { + result + .insert(BindingSpec { + context, + on: vec![key.to_owned()], + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid editor binding"); + } + } + result + } +} + +impl Keymap { + /// # Errors + /// Returns a recoverable configuration error. No partially loaded bindings + /// are installed, and execution/memory limits keep startup responsive. + pub fn load(path: &Path) -> Result { + let source = match std::fs::read_to_string(path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(error) => return Err(error.into()), + }; + Self::parse(&source) + .with_context(|| format!("invalid Lua configuration at {}", path.display())) + } + + /// # Errors + /// Returns Lua, action, key or chord-conflict errors. + pub fn parse(source: &str) -> Result { + if source.len() > 64 * 1024 { + bail!("Lua configuration exceeds 64 KiB"); + } + let lua = Lua::new_with( + StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8, + LuaOptions::default(), + )?; + lua.set_memory_limit(8 * 1024 * 1024)?; + let remaining = Arc::new(AtomicUsize::new(100)); + lua.set_hook( + HookTriggers::new().every_nth_instruction(10_000), + move |_, _| { + if remaining.fetch_sub(1, Ordering::Relaxed) == 0 { + return Err(mlua::Error::runtime( + "configuration instruction limit exceeded", + )); + } + Ok(VmState::Continue) + }, + )?; + let configuration: Configuration = + lua.from_value(lua.load(source).set_name("config.lua").eval()?)?; + let mut keymap = Self { + chats: configuration.chats, + colors: configuration.colors, + nerd_font: configuration.nerd_font, + ..Self::default() + }; + if let Some(text) = configuration.ghost_text { + keymap.ghost_text = text; + } + for binding in configuration.keymap { + keymap.insert(binding)?; + } + for (index, binding) in keymap.bindings.iter().enumerate() { + for other in &keymap.bindings[index + 1..] { + if binding.context == other.context + && (binding.keys.starts_with(&other.keys) + || other.keys.starts_with(&binding.keys)) + { + bail!( + "ambiguous key prefix: {} / {}", + display_keys(&binding.keys), + display_keys(&other.keys) + ); + } + } + } + Ok(keymap) + } + + fn insert(&mut self, spec: BindingSpec) -> Result<()> { + if spec.on.is_empty() || spec.on.len() > 4 { + bail!("a binding must contain one to four keys"); + } + if !(1..=9999).contains(&spec.count) { + bail!("binding count must be between 1 and 9999"); + } + if spec.count != 1 + && !matches!( + spec.run.as_str(), + "up" | "down" | "message_up" | "message_down" | "page_up" | "page_down" + ) + { + bail!("count is only supported for navigation actions"); + } + let keys = spec + .on + .iter() + .map(|key| { + if !key.starts_with('<') && key.chars().count() != 1 { + bail!("use separate keys for a chord: {key}"); + } + Key::from_str(key) + }) + .collect::>>()?; + if let Some(alias) = spec.run.strip_prefix("jump ") { + if !self.chats.contains_key(alias) { + bail!("unknown chat alias: {alias}"); + } + } else if !ACTIONS.contains(&spec.run.as_str()) { + bail!("unknown action: {}", spec.run); + } + self.bindings + .retain(|binding| !(binding.context == spec.context && binding.keys == keys)); + if spec.run == "noop" { + return Ok(()); + } + self.bindings.push(Binding { + context: spec.context, + keys, + description: spec.desc.unwrap_or_else(|| { + let action = spec.run.replace('_', " "); + if spec.count == 1 { + action + } else { + format!("{action} ×{}", spec.count) + } + }), + run: spec.run, + count: spec.count, + }); + Ok(()) + } + + pub fn reset(&mut self) { + self.pending.clear(); + self.count = 0; + self.last_key = None; + } + + #[must_use] + pub fn pending(&self) -> bool { + self.last_key.is_some() + } + + pub fn expire(&mut self) -> bool { + if self + .last_key + .is_some_and(|last| last.elapsed() > Duration::from_secs(1)) + { + self.reset(); + true + } else { + false + } + } + + pub fn feed(&mut self, context: Context, event: &KeyEvent) -> Resolution { + if event.kind == KeyEventKind::Release { + return Resolution::Unbound; + } + self.expire(); + if self.context != Some(context) { + self.reset(); + self.context = Some(context); + } + let Ok(key) = Key::try_from(event.clone()) else { + return Resolution::Unbound; + }; + if key.code == yazi_term::event::KeyCode::Escape && self.pending() { + self.reset(); + return Resolution::Pending(String::new()); + } + if matches!(context, Context::Chats | Context::Conversation) + && self.pending.is_empty() + && let Ok(digit) = key.to_string().parse::() + && (digit != 0 || self.count != 0) + && (self.count != 0 + || !self.bindings.iter().any(|binding| { + (binding.context == Context::Global || binding.context == context) + && binding.keys == [key] + })) + { + self.count = (self.count * 10 + digit).min(9999); + self.last_key = Some(Instant::now()); + return Resolution::Pending(self.count.to_string()); + } + self.pending.push(key); + for priority in [context, Context::Global] { + if let Some(binding) = self + .bindings + .iter() + .find(|binding| binding.context == priority && binding.keys == self.pending) + { + let result = Resolution::Action { + run: binding.run.clone(), + count: self.count.max(1).saturating_mul(binding.count).min(9999), + }; + self.reset(); + return result; + } + let candidates = self + .bindings + .iter() + .filter(|binding| { + binding.context == priority && binding.keys.starts_with(&self.pending) + }) + .collect::>(); + if !candidates.is_empty() { + self.last_key = Some(Instant::now()); + return Resolution::Pending( + candidates + .iter() + .map(|binding| { + format!("{} {}", display_keys(&binding.keys), binding.description) + }) + .collect::>() + .join(" · "), + ); + } + } + self.reset(); + Resolution::Unbound + } + + #[must_use] + pub fn hint(&self, context: Context, run: &str) -> String { + self.bindings + .iter() + .find(|binding| binding.context == context && binding.run == run) + .or_else(|| { + self.bindings.iter().find(|binding| { + binding.context == Context::Global + && binding.run == run + && !self.bindings.iter().any(|local| { + local.context == context + && (local.keys.starts_with(&binding.keys) + || binding.keys.starts_with(&local.keys)) + }) + }) + }) + .map_or_else( + || "unbound".to_owned(), + |binding| display_keys(&binding.keys), + ) + } + + #[must_use] + pub fn help(&self) -> Vec { + self.bindings + .iter() + .filter(|binding| binding.run != "noop") + .map(|binding| { + format!( + "{:?} {:18} {}", + binding.context, + display_keys(&binding.keys), + binding.description + ) + }) + .collect() + } +} + +fn display_keys(keys: &[Key]) -> String { + keys.iter() + .map(ToString::to_string) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::{Context, Keymap, Resolution}; + use yazi_term::event::{KeyCode, KeyEvent, Modifiers}; + + #[test] + fn lua_overrides_hints_and_keeps_counts_out_of_the_editor() { + let mut map = Keymap::parse( + r"return { + chats = { work = -1000000000042 }, + keymap = { + { context='compose', on={''}, run='newline' }, + { context='compose', on={''}, run='send' }, + { context='conversation', on={'g','w'}, run='jump work' }, + } + }", + ) + .unwrap(); + assert_eq!(map.hint(Context::Compose, "send"), ""); + let key = |c| KeyEvent::new(KeyCode::Char(c), Modifiers::empty()); + assert!(matches!( + map.feed(Context::Conversation, &key('2')), + Resolution::Pending(_) + )); + assert!(matches!( + map.feed(Context::Conversation, &key('0')), + Resolution::Pending(_) + )); + assert_eq!( + map.feed(Context::Conversation, &key('k')), + Resolution::Action { + run: "message_up".to_owned(), + count: 20 + } + ); + assert_eq!(map.feed(Context::Compose, &key('2')), Resolution::Unbound); + assert!(matches!( + map.feed(Context::Conversation, &key('g')), + Resolution::Pending(_) + )); + assert_eq!( + map.feed(Context::Conversation, &key('w')), + Resolution::Action { + run: "jump work".to_owned(), + count: 1 + } + ); + assert!( + map.help() + .iter() + .any(|line| line.contains("") && line.ends_with("send")) + ); + } + + #[test] + fn invalid_configuration_is_rejected_without_hanging() { + assert!(Keymap::parse("while true do end").is_err()); + assert!( + Keymap::parse("return { keymap={{context='conversation',on={'g'},run='latest'}} }") + .is_err() + ); + assert!( + Keymap::parse("return { keymap={{context='conversation',on={'x'},run='unknown'}} }") + .is_err() + ); + for count in [0, 10_000] { + assert!(Keymap::parse(&format!( + "return {{keymap={{{{context='conversation',on={{''}},run='message_up',count={count}}}}}}}" + )).is_err()); + } + } + + #[test] + fn fixed_counts_and_global_hints_follow_context_precedence() { + let mut map = Keymap::parse( + r"return {keymap={ + {context='conversation',on={''},run='message_up',count=20}, + {context='global',on={''},run='send'}, + {context='global',on={'g','w'},run='settings'}, + {context='compose',on={''},run='newline'}, + {context='input',on={''},run='clear'}, + }}", + ) + .unwrap(); + assert_eq!(map.hint(Context::Compose, "send"), ""); + assert_eq!(map.hint(Context::Input, "send"), "unbound"); + assert_eq!(map.hint(Context::Conversation, "settings"), "s"); + assert!(matches!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('2'), Modifiers::empty()) + ), + Resolution::Pending(_) + )); + assert_eq!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('u'), Modifiers::CONTROL) + ), + Resolution::Action { + run: "message_up".to_owned(), + count: 40 + } + ); + assert_eq!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('u'), Modifiers::CONTROL) + ), + Resolution::Action { + run: "message_up".to_owned(), + count: 20 + } + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9f1a465..d31a43e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,25 @@ pub mod app; +pub mod appearance; +pub mod cache; pub mod config; pub mod event; +pub mod folders; pub mod input; +pub mod keymap; pub mod media; pub mod model; +pub mod pins; +pub mod search; pub mod telegram; pub mod terminal; pub mod ui; pub mod update; -/// Version embedded into distributable binaries by CI. Source builds fall -/// back to the package's base development version. +/// CI can override the version. Git source installs use the same commit-height +/// versioning; archives without Git metadata use the package development version. pub const VERSION: &str = match option_env!("TERMGRAM_BUILD_VERSION") { Some(version) => version, - None => env!("CARGO_PKG_VERSION"), + None => env!("TERMGRAM_SOURCE_VERSION"), }; /// Human-readable build identity, separate from the updater's semantic version. diff --git a/src/main.rs b/src/main.rs index 3c81dc9..9cdf058 100644 --- a/src/main.rs +++ b/src/main.rs @@ -82,30 +82,59 @@ async fn main() -> Result<()> { let mut preview = PreviewRenderer::new(); let (mut app, settings) = load_app_settings(); let mut startup_warning = replacement_warning; + let lua_path = std::env::var_os("TERMGRAM_CONFIG") + .map(std::path::PathBuf::from) + .or_else(|| { + Settings::path() + .ok() + .map(|path| path.with_file_name("config.lua")) + }); + if let Some(path) = lua_path { + match termgram::keymap::Keymap::load(&path) { + Ok(keymap) => app.keymap = keymap, + Err(error) => { + startup_warning = Some(format!("{error:#}; using default bindings")); + } + } + } + if let Err(error) = app.load_appearance() { + startup_warning = Some(format!("Could not load appearance preferences: {error:#}")); + } + if let Err(error) = app.load_navigation() { + startup_warning = Some(format!("Could not load navigation preferences: {error:#}")); + } if let Some(warning) = &startup_warning { app.handle_network(NetworkEvent::Error(warning.clone())); } let mut update_check = spawn_update_check(settings); let mut update_preferences = settings; - let (mut commands, mut events, mut worker, base_config) = match Config::load() { + let (mut commands, mut events, mut worker, base_config, mut active_chat) = match Config::load() + { Ok(config) => match config.for_account(settings.active_account) { Ok(selected) => { let TelegramHandle { commands, events, task, + active_chat, } = telegram::spawn(selected); - (Some(commands), Some(events), Some(task), Some(config)) + ( + Some(commands), + Some(events), + Some(task), + Some(config), + Some(active_chat), + ) } Err(error) => { app.handle_network(NetworkEvent::Fatal(format!("{error:#}"))); - (None, None, None, Some(config)) + (None, None, None, Some(config), None) } }, Err(error) => { app.handle_network(NetworkEvent::Fatal(format!("{error:#}"))); - (None, None, None, None) + (None, None, None, None, None) } }; @@ -274,6 +303,7 @@ async fn main() -> Result<()> { &mut events, &mut worker, &mut pending_commands, + &mut active_chat, ) .await { @@ -282,6 +312,17 @@ async fn main() -> Result<()> { ))); } } + if let Some(active_chat) = &active_chat { + let target = app.sync_target(); + active_chat.send_if_modified(|current| { + if *current == target { + false + } else { + *current = target; + true + } + }); + } synchronize_update_preferences(&mut app, &mut update_preferences, &mut update_check); } @@ -533,103 +574,9 @@ fn dispatch( } fn reject_overflow(app: &mut AppState, command: TelegramCommand) { - let event = match command { - TelegramCommand::SendMessage { - chat_id, - local_id, - text, - reply_to, - } => NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::SendAttachment { - chat_id, - local_id, - path, - caption, - as_photo, - reply_to, - } => NetworkEvent::AttachmentSendFailed { - chat_id, - local_id, - path, - caption, - as_photo, - reply_to, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::DownloadAttachment { - chat_id, - message_id, - } => NetworkEvent::AttachmentDownloadFailed { - chat_id, - message_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::DownloadPreview { - chat_id, - message_id, - request_id, - .. - } => NetworkEvent::PreviewDownloadFailed { - chat_id, - message_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::ResolveTelegramLink { url } => NetworkEvent::LinkFailed { - url, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::ActivateButton { - chat_id, - message_id, - button_index: _, - } => NetworkEvent::ButtonFailed { - chat_id, - message_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::LoadHistory { - chat_id, - request_id, - } => NetworkEvent::HistoryFailed { - chat_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::LoadMessage { - chat_id, - source_message_id: _, - message_id, - request_id, - } => NetworkEvent::MessageLoadFailed { - chat_id, - message_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::MarkRead { chat_id } => NetworkEvent::ReadMarkFailed { - chat_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::RefreshDialogs => { - NetworkEvent::DialogsFailed("Telegram command queue is busy".to_owned()) - } - TelegramCommand::SwitchAccount { .. } | TelegramCommand::Shutdown => return, - TelegramCommand::StartQrAuth - | TelegramCommand::SubmitPhone(_) - | TelegramCommand::SubmitCode(_) - | TelegramCommand::SubmitPassword(_) - | TelegramCommand::RestartAuth => { - NetworkEvent::Fatal("Telegram command queue is busy; restart sign-in".to_owned()) - } - }; - app.handle_network(event); + if let Some(event) = command.failure("Telegram command queue is busy".to_owned()) { + app.handle_network(event); + } } async fn restart_telegram_worker( @@ -639,6 +586,7 @@ async fn restart_telegram_worker( events: &mut Option>, worker: &mut Option>, pending: &mut VecDeque, + active_chat: &mut Option>>, ) -> Result<()> { pending.clear(); if let Some(sender) = commands.take() { @@ -657,7 +605,9 @@ async fn restart_telegram_worker( commands: next_commands, events: next_events, task, + active_chat: next_active, } = telegram::spawn(selected); + *active_chat = Some(next_active); *commands = Some(next_commands); *events = Some(next_events); *worker = Some(task); diff --git a/src/model.rs b/src/model.rs index e73763d..0d0bded 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,19 +1,25 @@ +use serde::{Deserialize, Serialize}; + use chrono::{DateTime, Local, TimeZone, Utc}; use std::path::Path; pub type ChatId = i64; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Chat { pub id: ChatId, pub title: String, pub kind: ChatKind, + #[serde(default)] + pub membership: crate::folders::ChatMembership, pub unread: u32, pub last_message: String, + #[serde(default)] + pub last_message_id: Option, pub last_activity: Option>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum ChatKind { Direct, Group, @@ -24,8 +30,10 @@ pub enum ChatKind { /// /// This deliberately contains metadata only. Telegram's file reference stays /// in the network layer and is refreshed on demand before a download. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Attachment { + #[serde(default)] + pub source_id: Option, pub kind: AttachmentKind, pub file_name: Option, pub mime_type: Option, @@ -34,7 +42,7 @@ pub struct Attachment { pub fallback_emoji: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum AttachmentKind { Photo, File, @@ -91,8 +99,10 @@ impl Attachment { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Message { + #[serde(default)] + pub pinned: bool, pub id: i32, pub chat_id: ChatId, pub sender: String, @@ -114,20 +124,20 @@ pub struct Message { pub buttons: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageLink { pub label: String, pub url: String, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageButton { pub label: String, pub index: u16, pub kind: MessageButtonKind, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum MessageButtonKind { Url, Callback, @@ -143,7 +153,7 @@ impl MessageButtonKind { } /// Lightweight metadata used to render and navigate a reply. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct ReplyInfo { pub message_id: i32, /// Stable dialog identifier for the target. Telegram permits replies to a @@ -154,7 +164,7 @@ pub struct ReplyInfo { pub sender: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum Delivery { Pending, Sent, diff --git a/src/pins.rs b/src/pins.rs new file mode 100644 index 0000000..2bdeede --- /dev/null +++ b/src/pins.rs @@ -0,0 +1,54 @@ +//! Telegram pin identities and ordering, independent of protocol and UI types. + +use crate::model::ChatId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DialogPins { + pub main: Vec, + pub archive: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DialogScope { + Main, + Archive, + Filter(i32), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DialogAction { + Set(bool), + MoveUp, + MoveDown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MessageAction { + Pin { notify: bool, only_self: bool }, + Unpin, + UnpinAll, +} + +pub const PAGE_SIZE: usize = 30; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MessagePage { + pub messages: Vec, + pub total: Option, + pub before: i32, + pub next: Option, +} + +/// Move within the complete server order, including peers hidden by the UI. +pub(crate) fn move_pin(order: &mut [T], position: usize, action: DialogAction) { + let target = match action { + DialogAction::MoveUp => position.checked_sub(1), + DialogAction::MoveDown => position.checked_add(1).filter(|index| *index < order.len()), + DialogAction::Set(_) => None, + }; + if let Some(target) = target { + order.swap(position, target); + } +} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..8f84c02 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,280 @@ +//! Bounded, cancellable regex searches over the account's local message cache. +use crate::{ + event::NetworkEvent, + model::{ChatId, Message}, +}; +use anyhow::{Result, bail}; +use libsql::{OpenFlags, params}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +const PAGE_SIZE: usize = 100; +const SCAN_BATCH: i64 = 128; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Cursor(pub i64, pub ChatId, pub i32); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Request { + pub id: u64, + pub pattern: String, + /// None means all cached conversations; Some(empty) is an empty scope. + pub chats: Option>, + pub before: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Page { + pub messages: Vec, + pub next: Option, + pub cached_messages: usize, + pub oldest: Option, + pub newest: Option, +} + +pub(crate) struct Worker { + path: PathBuf, + jobs: tokio::task::JoinSet<(u64, Arc, Result)>, + cancel: Arc, + pending: Option, +} + +impl Worker { + pub fn new(path: PathBuf) -> Self { + Self { + path, + jobs: tokio::task::JoinSet::new(), + cancel: Arc::default(), + pending: None, + } + } + pub fn queue(&mut self, request: Request) { + self.cancel(); + self.pending = Some(request); + } + pub fn cancel(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + self.pending = None; + } + pub fn start_pending(&mut self) { + if !self.jobs.is_empty() { + return; + } + let Some(request) = self.pending.take() else { + return; + }; + self.cancel = Arc::default(); + let cancel = self.cancel.clone(); + let path = self.path.clone(); + let runtime = tokio::runtime::Handle::current(); + self.jobs.spawn_blocking(move || { + let result = runtime.block_on(scan(&path, &request, &cancel)); + (request.id, cancel, result) + }); + } + pub fn running(&self) -> bool { + !self.jobs.is_empty() + } + pub async fn next(&mut self) -> Option { + match self.jobs.join_next().await? { + Ok((_, cancel, _)) if cancel.load(Ordering::Relaxed) => None, + Ok((request_id, _, Ok(page))) => Some(NetworkEvent::SearchResults { request_id, page }), + Ok((request_id, _, Err(error))) => Some(NetworkEvent::SearchFailed { + request_id, + error: format!("{error:#}"), + }), + Err(error) => Some(NetworkEvent::Error(format!( + "Local search task failed: {error}" + ))), + } + } +} + +impl Drop for Worker { + fn drop(&mut self) { + self.cancel(); + } +} + +async fn scan(path: &Path, request: &Request, cancel: &AtomicBool) -> Result { + if request.pattern.len() > 4096 { + bail!("Regular expression is longer than 4096 bytes"); + } + let regex = regex::RegexBuilder::new(&request.pattern) + .size_limit(4 * 1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024) + .build()?; + let database = libsql::Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_millis(250))?; + let scope = request + .chats + .as_ref() + .map(|ids| ids.iter().copied().collect::>()); + let mut page = Page { + messages: Vec::new(), + next: None, + cached_messages: 0, + oldest: None, + newest: None, + }; + let mut cursor = Cursor(i64::MAX, i64::MAX, i32::MAX); + let mut last_match = None; + let mut full = false; + loop { + if cancel.load(Ordering::Relaxed) { + bail!("Search cancelled"); + } + let mut rows = connection.query( + "SELECT m.timestamp,m.chat_id,m.id,m.data FROM messages m JOIN chats c ON c.id=m.chat_id WHERE m.data IS NOT NULL AND (m.timestamp,m.chat_id,m.id)<(?1,?2,?3) ORDER BY m.timestamp DESC,m.chat_id DESC,m.id DESC LIMIT ?4", + params![cursor.0, cursor.1, cursor.2, SCAN_BATCH]).await?; + let mut scanned = 0; + while let Some(row) = rows.next().await? { + if cancel.load(Ordering::Relaxed) { + bail!("Search cancelled"); + } + cursor = Cursor(row.get(0)?, row.get(1)?, row.get(2)?); + scanned += 1; + if scope.as_ref().is_some_and(|ids| !ids.contains(&cursor.1)) { + continue; + } + page.cached_messages += 1; + page.oldest = Some(cursor.0); + page.newest.get_or_insert(cursor.0); + if full + || request.before.is_some_and(|before| { + (cursor.0, cursor.1, cursor.2) >= (before.0, before.1, before.2) + }) + { + continue; + } + let message: Message = serde_json::from_str(&row.get::(3)?)?; + if regex.is_match(&message.text) { + if page.messages.len() == PAGE_SIZE { + page.next = last_match; + full = true; + } else { + page.messages.push(message); + last_match = Some(cursor); + } + } + } + if scanned < SCAN_BATCH { + break; + } + } + Ok(page) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + cache::Store, + model::{Chat, ChatKind, Delivery}, + }; + use chrono::TimeZone; + + #[tokio::test] + async fn regex_search_pages_scopes_tombstones_and_stale_context() { + let dir = std::env::temp_dir().join(format!("termgram-search-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cache.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = |id| Chat { + id, + title: format!("Chat {id}"), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + membership: crate::folders::ChatMembership::default(), + }; + let mut events = vec![NetworkEvent::Dialogs(vec![chat(42), chat(43)])]; + for id in 1..=206 { + events.push(NetworkEvent::NewMessage(Message { + pinned: false, + id, + chat_id: if id == 206 { 43 } else { 42 }, + sender: "Ada".to_owned(), + reply_to: None, + text: format!("東京 error {id}"), + timestamp: chrono::Utc.timestamp_opt(i64::from(id), 0).unwrap(), + outgoing: false, + delivery: Delivery::Read, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + })); + } + events.push(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }); + store.apply(&events).await.unwrap(); + let mut request = Request { + id: 1, + pattern: r"東京.*error \d+$".to_owned(), + chats: Some(vec![42]), + before: None, + }; + let cancelled = AtomicBool::new(false); + let first = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(first.cached_messages, 204); + assert_eq!(first.messages.len(), 100); + assert_eq!(first.messages[0].id, 205); + request.before = first.next; + let second = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(second.messages.len(), 100); + assert!( + second + .messages + .iter() + .all(|message| !first.messages.iter().any(|other| other.id == message.id)) + ); + request.before = second.next; + let last = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(last.messages.len(), 4); + assert!(last.next.is_none()); + assert!(last.messages.iter().all(|message| message.id != 11)); + request.before = None; + request.chats = Some(vec![43]); + assert_eq!( + scan(&path, &request, &cancelled) + .await + .unwrap() + .messages + .len(), + 1 + ); + let context = store.context(42, 50).await.unwrap(); + assert!(context.iter().any(|message| message.id == 49)); + assert!(context.iter().any(|message| message.id == 51)); + store + .apply(&[NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![50], + }]) + .await + .unwrap(); + assert!(store.context(42, 50).await.is_err()); + request.pattern = "(".to_owned(); + assert!(scan(&path, &request, &cancelled).await.is_err()); + request.pattern = "error".to_owned(); + cancelled.store(true, Ordering::Relaxed); + assert!(scan(&path, &request, &cancelled).await.is_err()); + drop(store); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/telegram/folders.rs b/src/telegram/folders.rs new file mode 100644 index 0000000..e67666f --- /dev/null +++ b/src/telegram/folders.rs @@ -0,0 +1,121 @@ +//! Small protocol adapter for native folders and inherited notification settings. +use crate::{ + folders::{ChatMembership, Folder}, + model::ChatId, + model::sanitize_terminal_line, +}; +use anyhow::Result; +use grammers_client::{Client, tl}; +use grammers_session::types::PeerId; + +pub(super) async fn load(client: &Client, self_id: i64) -> Result> { + let tl::enums::messages::DialogFilters::Filters(result) = client + .invoke(&tl::functions::messages::GetDialogFilters {}) + .await?; + let mut folders: Vec<_> = result + .filters + .into_iter() + .map(|filter| match filter { + tl::enums::DialogFilter::Default => Folder::all(), + tl::enums::DialogFilter::Filter(filter) => { + let tl::enums::TextWithEntities::Entities(title) = filter.title; + Folder { + id: filter.id, + title: sanitize_terminal_line(&title.text), + pinned: peer_ids(filter.pinned_peers, self_id), + include: peer_ids(filter.include_peers, self_id), + exclude: peer_ids(filter.exclude_peers, self_id), + contacts: filter.contacts, + non_contacts: filter.non_contacts, + bots: filter.bots, + groups: filter.groups, + broadcasts: filter.broadcasts, + exclude_muted: filter.exclude_muted, + exclude_read: filter.exclude_read, + exclude_archived: filter.exclude_archived, + } + } + tl::enums::DialogFilter::Chatlist(filter) => { + let tl::enums::TextWithEntities::Entities(title) = filter.title; + Folder { + id: filter.id, + title: sanitize_terminal_line(&title.text), + pinned: peer_ids(filter.pinned_peers, self_id), + include: peer_ids(filter.include_peers, self_id), + ..Folder::default() + } + } + }) + .collect(); + if !folders.iter().any(|folder| folder.id == 0) { + folders.insert(0, Folder::all()); + } + folders.push(Folder::archive()); + Ok(folders) +} + +fn peer_ids(peers: Vec, self_id: i64) -> Vec { + peers + .into_iter() + .filter_map(|peer| peer_id(&peer, self_id)) + .collect() +} + +pub(super) fn peer_id(peer: &tl::enums::InputPeer, self_id: i64) -> Option { + match peer { + tl::enums::InputPeer::User(peer) => PeerId::user(peer.user_id), + tl::enums::InputPeer::Chat(peer) => PeerId::chat(peer.chat_id), + tl::enums::InputPeer::Channel(peer) => PeerId::channel(peer.channel_id), + tl::enums::InputPeer::UserFromMessage(peer) => PeerId::user(peer.user_id), + tl::enums::InputPeer::ChannelFromMessage(peer) => PeerId::channel(peer.channel_id), + tl::enums::InputPeer::PeerSelf => PeerId::user(self_id), + tl::enums::InputPeer::Empty => None, + } + .and_then(PeerId::bot_api_dialog_id) +} + +pub(super) async fn default_mutes(client: &Client) -> Result<(i64, i64)> { + let (users, groups) = tokio::try_join!( + client.invoke(&tl::functions::account::GetNotifySettings { + peer: tl::enums::InputNotifyPeer::InputNotifyUsers + }), + client.invoke(&tl::functions::account::GetNotifySettings { + peer: tl::enums::InputNotifyPeer::InputNotifyChats + }), + )?; + let tl::enums::PeerNotifySettings::Settings(users) = users; + let tl::enums::PeerNotifySettings::Settings(groups) = groups; + Ok(( + i64::from(users.mute_until.unwrap_or(0)), + i64::from(groups.mute_until.unwrap_or(0)), + )) +} + +pub(super) fn membership( + dialog: &grammers_client::peer::Dialog, + defaults: (i64, i64), +) -> ChatMembership { + let tl::enums::Dialog::Dialog(raw) = &dialog.raw else { + return ChatMembership::default(); + }; + let user = match dialog.peer() { + grammers_client::peer::Peer::User(user) => Some(user), + _ => None, + }; + let tl::enums::PeerNotifySettings::Settings(notify) = &raw.notify_settings; + ChatMembership { + contact: user.is_some_and(grammers_client::peer::User::contact), + bot: user.is_some_and(grammers_client::peer::User::is_bot), + archived: raw.folder_id == Some(1), + unread_mark: raw.unread_mark, + mentions: raw.unread_mentions_count > 0, + mute_until: notify.mute_until.map_or( + if user.is_some() { + defaults.0 + } else { + defaults.1 + }, + i64::from, + ), + } +} diff --git a/src/telegram/local.rs b/src/telegram/local.rs new file mode 100644 index 0000000..1f9ae4a --- /dev/null +++ b/src/telegram/local.rs @@ -0,0 +1,287 @@ +//! The local store remains available while Telegram connects or authenticates. + +use super::{ + COMMAND_QUEUE_CAPACITY, Config, EVENT_QUEUE_CAPACITY, NetworkEvent, Result, TelegramCommand, + VecDeque, mpsc, run, +}; +use crate::{ + cache::{Store, SyncCursor}, + model::Chat, +}; + +pub(super) struct Bootstrap { + pub cursor: SyncCursor, + pub chats: Vec, + pub account_id: Option, + pub cache_owner: std::sync::Arc, +} +use tokio::{ + task::JoinSet, + time::{Duration, MissedTickBehavior}, +}; + +pub(super) async fn serve( + config: Config, + mut commands: mpsc::Receiver, + events: mpsc::Sender, + active_chat: tokio::sync::watch::Receiver>, +) -> Result<()> { + config.prepare_session_dir()?; + let mut cache_path = config.session_path.as_os_str().to_os_string(); + cache_path.push(".cache.sqlite3"); + let mut store = Store::open(std::path::Path::new(&cache_path)).await?; + let (user_name, chats) = store.snapshot().await?; + if let Some(user_id) = store.account_id().await? { + events + .send(NetworkEvent::AccountIdentity { user_id }) + .await?; + } + if !chats.is_empty() { + events + .send(NetworkEvent::CachedSnapshot { + user_name, + chats: chats.clone(), + }) + .await?; + } + events + .send(NetworkEvent::Folders(store.folders().await?)) + .await?; + events + .send(NetworkEvent::DialogPins(store.dialog_pins().await?)) + .await?; + let bootstrap = Bootstrap { + cache_owner: store.owner(), + cursor: store.cursor().await?, + chats, + account_id: store.account_id().await?, + }; + let (network_tx, network_commands) = mpsc::channel(COMMAND_QUEUE_CAPACITY); + let (network_events, mut network_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); + let mut tasks = JoinSet::new(); + tasks.spawn(run( + config, + network_commands, + network_events, + bootstrap, + active_chat, + )); + let mut search = crate::search::Worker::new(std::path::PathBuf::from(cache_path)); + let mut pending = VecDeque::new(); + let mut changes = Vec::new(); + let mut ready = false; + let mut flush = tokio::time::interval(Duration::from_millis(100)); + flush.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + search.start_pending(); + tokio::select! { + result = search.next(), if search.running() => { if let Some(event) = result { events.send(event).await?; } }, + command = commands.recv() => { + let Some(command) = command else { break; }; + if serve_cached(&command, &mut store, &mut changes, &events, &mut search).await? { continue; } + if authentication_command(&command) { + pending.push_front(command); + } else if pending.len() < COMMAND_QUEUE_CAPACITY { + pending.push_back(command); + } else if let Some(event) = command.failure("Telegram command queue is busy".to_owned()) { + store.apply(std::slice::from_ref(&event)).await?; + events.send(event).await?; + } + } + permit = network_tx.reserve(), if pending.front().is_some_and(|command| ready || authentication_command(command)) => { + if let Ok(permit) = permit { + permit.send(pending.pop_front().expect("pending command")); + } else { break; } + } + event = network_rx.recv() => { + let Some(event) = event else { + store.apply(&changes).await?; + return tasks.join_next().await.transpose()?.unwrap_or(Ok(())); + }; + match &event { + NetworkEvent::Ready { .. } => ready = true, + NetworkEvent::Auth(_) => ready = false, + NetworkEvent::CacheAccountReset { .. } => { pending.clear(); search.cancel(); }, + _ => {} + } + let checkpoint = matches!(&event, NetworkEvent::SyncCheckpoint(_)); + changes.push(event.clone()); + if checkpoint || changes.len() >= 256 { + store.apply(&changes).await?; + changes.clear(); + } + events.send(event).await?; + } + _ = flush.tick(), if !changes.is_empty() => { + store.apply(&changes).await?; + changes.clear(); + } + } + } + store.apply(&changes).await?; + tasks.shutdown().await; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn serve_cached( + command: &TelegramCommand, + store: &mut Store, + changes: &mut Vec, + events: &mpsc::Sender, + search: &mut crate::search::Worker, +) -> Result { + if matches!( + command, + TelegramCommand::LoadHistory { .. } + | TelegramCommand::LoadPinnedMessages { .. } + | TelegramCommand::LoadPinnedContext { .. } + | TelegramCommand::LoadOlder { .. } + | TelegramCommand::LoadCachedContext { .. } + | TelegramCommand::DownloadAttachment { .. } + | TelegramCommand::SearchCached(_) + ) { + store.apply(changes).await?; + changes.clear(); + } + match command { + TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id, + } => { + let page = store.pinned_messages(*chat_id, *before).await?; + events + .send(NetworkEvent::PinnedMessages { + chat_id: *chat_id, + request_id: *request_id, + page, + }) + .await?; + } + TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id, + } => { + let messages = store + .history( + *chat_id, + Some(message_id.saturating_add(1)), + super::HISTORY_LIMIT, + ) + .await?; + if messages.iter().any(|message| message.id == *message_id) { + events + .send(NetworkEvent::PinnedContext { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + messages, + }) + .await?; + } + } + TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + media_id, + } => { + if let Some(path) = store.attachment(*chat_id, *message_id, *media_id).await? { + events + .send(NetworkEvent::AttachmentDownloaded { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + path, + }) + .await?; + return Ok(true); + } + store + .apply(&[NetworkEvent::AttachmentDownloadStarted { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + media_id: *media_id, + }]) + .await?; + } + TelegramCommand::SearchCached(request) => { + search.queue(request.clone()); + return Ok(true); + } + TelegramCommand::CancelSearch => { + search.cancel(); + return Ok(true); + } + TelegramCommand::LoadCachedContext { + chat_id, + message_id, + request_id, + } => { + let event = match store.context(*chat_id, *message_id).await { + Ok(messages) => NetworkEvent::CachedContext { + request_id: *request_id, + chat_id: *chat_id, + message_id: *message_id, + messages, + }, + Err(error) => NetworkEvent::SearchFailed { + request_id: *request_id, + error: format!("{error:#}"), + }, + }; + events.send(event).await?; + return Ok(true); + } + TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id, + } => { + if let Some(messages) = store.older_page(*chat_id, *before_id).await? { + events + .send(NetworkEvent::OlderHistory { + chat_id: *chat_id, + request_id: *request_id, + before_id: *before_id, + messages, + }) + .await?; + return Ok(true); + } + } + TelegramCommand::LoadHistory { + chat_id, + request_id, + } => { + let messages = store.history(*chat_id, None, super::HISTORY_LIMIT).await?; + if !messages.is_empty() { + events + .send(NetworkEvent::CachedHistory { + chat_id: *chat_id, + request_id: *request_id, + messages, + }) + .await?; + } + } + _ => {} + } + Ok(false) +} + +fn authentication_command(command: &TelegramCommand) -> bool { + matches!( + command, + TelegramCommand::StartQrAuth + | TelegramCommand::SubmitPhone(_) + | TelegramCommand::SubmitCode(_) + | TelegramCommand::SubmitPassword(_) + | TelegramCommand::RestartAuth + | TelegramCommand::Shutdown + ) +} diff --git a/src/telegram/media_cache.rs b/src/telegram/media_cache.rs new file mode 100644 index 0000000..df20753 --- /dev/null +++ b/src/telegram/media_cache.rs @@ -0,0 +1,161 @@ +//! Managed media files. Temporary files are owned by tempfile until completion. +use crate::model::ChatId; +use anyhow::{Context, Result, bail}; +use std::{ + path::{Path, PathBuf}, + time::SystemTime, +}; +use tempfile::TempPath; + +const MAX_FILES: usize = 512; +const MAX_BYTES: u64 = 1024 * 1024 * 1024; + +pub(super) async fn prepare( + root: PathBuf, + account: i64, + owner: Option>, +) -> Result { + tokio::task::spawn_blocking(move || { + let _owner = owner; + let directory = root.join(account.to_string()); + for path in [&root, &directory] { + std::fs::create_dir_all(path)?; + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("media cache must be a real directory"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + } + // The account cache lock guarantees these cannot belong to another + // running instance. Cleanup happens before the first transfer starts. + for entry in std::fs::read_dir(&directory)? { + let entry = entry?; + if entry.file_type()?.is_file() && is_partial(&entry.path()) { + std::fs::remove_file(entry.path())?; + } + } + prune(&directory, None, MAX_FILES, MAX_BYTES)?; + Ok(directory) + }) + .await? +} + +pub(super) fn temporary(directory: &Path) -> Result { + Ok(tempfile::Builder::new() + .prefix(".tg-download-") + .suffix(".part") + .tempfile_in(directory)? + .into_temp_path()) +} + +pub(super) async fn finish( + mut temporary: TempPath, + directory: PathBuf, + chat: ChatId, + message: i32, + name: &str, + owner: Option>, +) -> Result { + loop { + let path = super::unique_download_path(&directory, chat, message, name).await?; + match temporary.persist_noclobber(&path) { + Ok(()) => { + let keep = path.clone(); + tokio::task::spawn_blocking(move || { + let _owner = owner; + prune(&directory, Some(&keep), MAX_FILES, MAX_BYTES) + }) + .await??; + return Ok(path); + } + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + temporary = error.path; + } + Err(error) => return Err(error.error).context("could not finish media cache file"), + } + } +} + +// This suffix is generated by this module, not a user-selected extension. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn is_partial(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".tg-download-") && name.ends_with(".part")) +} + +fn prune(directory: &Path, keep: Option<&Path>, max_files: usize, max_bytes: u64) -> Result<()> { + let mut files = Vec::new(); + let mut bytes = 0_u64; + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if !entry.file_type()?.is_file() || is_partial(&entry.path()) { + continue; + } + let metadata = entry.metadata()?; + bytes = bytes.saturating_add(metadata.len()); + files.push(( + metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + entry.path(), + metadata.len(), + )); + } + files.sort_by_key(|(time, _, _)| *time); + let mut count = files.len(); + for (_, path, size) in files { + if count <= max_files && bytes <= max_bytes { + break; + } + if keep == Some(path.as_path()) { + continue; + } + match std::fs::remove_file(path) { + Ok(()) => { + count = count.saturating_sub(1); + bytes = bytes.saturating_sub(size); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn retention_preserves_partial_and_current_files_without_overwriting() { + let root = tempfile::tempdir().unwrap(); + let directory = prepare(root.path().join("media"), 7, None).await.unwrap(); + let old = directory.join("old.txt"); + std::fs::write(&old, "old").unwrap(); + let active = temporary(&directory).unwrap(); + std::fs::write(&active, "partial").unwrap(); + let complete = temporary(&directory).unwrap(); + std::fs::write(&complete, "done").unwrap(); + let path = finish(complete, directory.clone(), 42, 1, "file.txt", None) + .await + .unwrap(); + let another = temporary(&directory).unwrap(); + std::fs::write(&another, "next").unwrap(); + let second = finish(another, directory.clone(), 42, 1, "file.txt", None) + .await + .unwrap(); + assert_ne!(path, second); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "done"); + prune(&directory, Some(&second), 1, 4).unwrap(); + assert!(second.is_file()); + assert!(active.is_file()); + assert!(!old.exists()); + assert!(!path.exists()); + let interrupted = active.keep().unwrap(); + prepare(root.path().join("media"), 7, None).await.unwrap(); + assert!(!interrupted.exists()); + assert!(second.is_file()); + } +} diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index 198c95e..7d6a090 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -1,3 +1,9 @@ +mod folders; +mod local; +mod media_cache; +mod pins; +mod requests; + use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -7,14 +13,16 @@ use anyhow::{Context, Result, anyhow, bail}; use grammers_client::client::{LoginToken, PasswordToken, UpdatesConfiguration}; use grammers_client::media::{Media, PhotoSize}; use grammers_client::message::{InputMessage, Message as TelegramMessage}; -use grammers_client::peer::{Peer, User}; +use grammers_client::peer::{Dialog, Peer, User}; use grammers_client::sender::SenderPoolHandle; use grammers_client::tl::{self, enums::Dialog as RawDialog}; use grammers_client::update::Update; use grammers_client::{Client, InvocationError, SenderPool, SignInError}; use grammers_session::Session; use grammers_session::storages::SqliteSession; -use grammers_session::types::{PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState}; +use grammers_session::types::{ + ChannelKind, PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState, +}; use grammers_session::updates::UpdatesLike; use tokio::sync::mpsc; use tokio::task::JoinSet; @@ -29,7 +37,6 @@ use crate::model::{ const HISTORY_LIMIT: usize = 80; const COMMAND_QUEUE_CAPACITY: usize = 32; const EVENT_QUEUE_CAPACITY: usize = 64; -const UPDATE_QUEUE_LIMIT: usize = 256; const TRANSIENT_SENDER_NAME_LIMIT: usize = 256; const MESSAGE_SENDER_CACHE_LIMIT: usize = 512; const UNRESOLVED_REFRESH_COOLDOWN: Duration = Duration::from_secs(5); @@ -44,10 +51,24 @@ pub struct TelegramHandle { pub commands: mpsc::Sender, pub events: mpsc::Receiver, pub task: tokio::task::JoinHandle<()>, + pub active_chat: tokio::sync::watch::Sender>, +} + +#[derive(Default)] +struct MetadataRefresh { + in_flight: bool, + dirty: bool, } #[derive(Default)] struct WorkerCache { + dialogs: MetadataRefresh, + folders: MetadataRefresh, + dialog_pins: MetadataRefresh, + message_pins: MetadataRefresh, + active_chat: Option, + self_id: i64, + channel_pts: HashMap, peers: HashMap, /// Peers opened explicitly through supported Telegram links remain usable /// even when they are not part of the account's dialog snapshot. @@ -74,8 +95,10 @@ struct WorkerCache { /// triggering a complete dialog scan. Once it expires, any unresolved peer /// can retry the refresh. last_unresolved_refresh: Option, - /// Created lazily so text-only sessions never touch the temporary folder. + /// Created lazily so text-only sessions never create a media directory. download_dir: Option, + media_root: PathBuf, + cache_owner: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -125,51 +148,34 @@ enum TransferCompletion { result: Result, }, Download { + request_id: u64, chat_id: ChatId, message_id: i32, result: Result, }, } -struct PartialDownload { - path: PathBuf, - complete: bool, -} - -impl PartialDownload { - fn new(path: PathBuf) -> Self { - Self { - path, - complete: false, - } - } - - fn finish(mut self) -> PathBuf { - self.complete = true; - self.path.clone() - } -} - -impl Drop for PartialDownload { - fn drop(&mut self) { - if !self.complete { - let _ = std::fs::remove_file(&self.path); - } - } -} - #[must_use] pub fn spawn(config: Config) -> TelegramHandle { + let (active_tx, active_rx) = tokio::sync::watch::channel(None); let (command_tx, command_rx) = mpsc::channel(COMMAND_QUEUE_CAPACITY); let (event_tx, event_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); let task = tokio::spawn(async move { - if let Err(error) = Box::pin(run(config, command_rx, event_tx.clone())).await { + if let Err(error) = Box::pin(local::serve( + config, + command_rx, + event_tx.clone(), + active_rx, + )) + .await + { let _ = event_tx .send(NetworkEvent::Fatal(describe_worker_error(&error))) .await; } }); TelegramHandle { + active_chat: active_tx, commands: command_tx, events: event_rx, task, @@ -190,6 +196,8 @@ async fn run( config: Config, mut commands: mpsc::Receiver, events: mpsc::Sender, + mut bootstrap: local::Bootstrap, + mut active_chat: tokio::sync::watch::Receiver>, ) -> Result<()> { config.prepare_session_dir()?; events @@ -209,7 +217,10 @@ async fn run( mut updates, } = SenderPool::new(session.clone(), config.api_id); let client = Client::new(handle.clone()); - let pool_task = tokio::spawn(runner.run()); + // JoinSet aborts the sender on account switches and initialization failures, + // including when the owner itself is aborted during shutdown. + let mut pool_tasks = JoinSet::new(); + pool_tasks.spawn(runner.run()); let result: Result<()> = Box::pin(async { let initially_authorized = client.is_authorized().await?; @@ -244,11 +255,33 @@ async fn run( } } }; + let user_id = me.id().bare_id().context("Telegram account has no stable identifier")?; + if bootstrap.account_id.is_some_and(|previous| previous != user_id) { + bootstrap.cursor = crate::cache::SyncCursor::default(); + bootstrap.chats.clear(); + events.send(NetworkEvent::CacheAccountReset { user_id }).await?; + } + events.send(NetworkEvent::AccountIdentity { user_id }).await?; let user_name = safe_name(me.first_name(), "You"); events.send(NetworkEvent::Ready { user_name }).await.ok(); - let mut cache = WorkerCache::default(); - load_dialogs(&client, &mut cache, &events).await?; + let mut media_root = config.session_path.as_os_str().to_os_string(); + media_root.push(".media"); + let mut cache = WorkerCache { cache_owner: Some(bootstrap.cache_owner.clone()), media_root: PathBuf::from(media_root), self_id: user_id, folders: MetadataRefresh { dirty: true, ..MetadataRefresh::default() }, ..WorkerCache::default() }; + for chat in bootstrap.chats { + if let Some(id) = PeerId::from_bot_api_dialog_id(chat.id) { + if let Some(peer) = session.peer_ref(id).await? { cache.peers.insert(chat.id, peer); } + if let Some(top) = chat.last_message_id { cache.top_messages.insert(chat.id, top); } + cache.names.insert(id, chat.title); + if chat.kind == ChatKind::Group && id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + } + } + let cursor = bootstrap.cursor; + for &(id, pts) in &cursor.channels { + cache.channel_pts.insert(PeerId::channel_unchecked(id).bot_api_dialog_id_unchecked(), pts); + } events .send(NetworkEvent::Status(ConnectionStatus::Online)) .await @@ -259,121 +292,156 @@ async fn run( updates, UpdatesConfiguration { catch_up: true, - update_queue_limit: Some(UPDATE_QUEUE_LIMIT), + // Never truncate catch-up batches; losing payloads while advancing + // PTS would leave the view permanently stale. + update_queue_limit: None, }, ) .await .map_err(anyhow::Error::from_boxed)?; + updates.restore_state(UpdatesState { + pts: cursor.pts, qts: cursor.qts, date: cursor.date, seq: cursor.seq, + channels: cursor.channels.into_iter().map(|(id, pts)| grammers_session::types::ChannelState { id, pts }).collect(), + }); + let active_channel = updates.active_channel(); + cache.active_chat = *active_chat.borrow(); + cache.message_pins.dirty = true; + signal_active_channel(&cache, *active_chat.borrow_and_update(), &active_channel); let mut recovering = false; let mut transfers = JoinSet::new(); - - loop { - tokio::select! { - command = commands.recv() => { - let Some(command) = command else { break; }; - match Box::pin(handle_command( - command, - &client, - &mut cache, - &events, - &mut transfers, - )).await { - Ok(true) => break, - Ok(false) => {} - Err(error) => { - let message = format!("Telegram request failed: {error:#}"); - events - .send(NetworkEvent::Error(message)) - .await - .ok(); - } + let mut requests = JoinSet::new(); + cache.dialog_pins.dirty = true; + requests::refresh_dialogs(&client, &mut cache, &events, &mut requests).await?; + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + + 'worker: loop { + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + // Grammers can change its message-box state before awaiting peer + // storage. Keep this future alive when commands or RPCs complete. + let next = updates.next_batch(); + tokio::pin!(next); + let update = loop { + tokio::select! { + update = &mut next => break update, + changed = active_chat.changed() => { + if changed.is_err() { break 'worker; } + cache.active_chat = *active_chat.borrow(); + cache.message_pins.dirty = true; + signal_active_channel(&cache, *active_chat.borrow_and_update(), &active_channel); } - } - update = updates.next() => { - if matches!(update, Err(InvocationError::Dropped)) { - bail!("Telegram update stream closed"); + command = commands.recv(), if requests.len() < requests::MAX_REQUESTS => { + let Some(command) = command else { break 'worker; }; + if handle_command(command, &client, &mut cache, &events, + &mut transfers, &mut requests).await? { + break 'worker; + } } - let update_failed = update.is_err(); - if let Err(error) = Box::pin(process_update( - update, - &client, - &mut cache, - &events, - &mut recovering, - )).await { - events - .send(NetworkEvent::Error(format!( - "Could not process a Telegram update: {error:#}" - ))) - .await - .ok(); + completion = requests.join_next(), if !requests.is_empty() => { + match completion { + Some(Ok(completion)) => { + requests::complete(completion, &mut cache, &events).await?; + signal_active_channel(&cache, *active_chat.borrow(), &active_channel); + } + Some(Err(error)) => bail!("Telegram request task failed: {error}"), + None => {} + } } - if update_failed { - // Avoid a tight retry loop if difference recovery is temporarily failing. - tokio::time::sleep(Duration::from_millis(250)).await; + completion = transfers.join_next(), if !transfers.is_empty() => { + match completion { + Some(Ok(completion)) => process_transfer_completion(completion, &mut cache, &events).await?, + Some(Err(error)) => bail!("Telegram transfer task failed: {error}"), + None => {} + } } } - transfer = transfers.join_next(), if !transfers.is_empty() => { - match transfer { - Some(Ok(completion)) => { - Box::pin(process_transfer_completion( - completion, - &client, - &mut cache, - &events, - )).await?; - } - Some(Err(error)) if !error.is_cancelled() => { - events - .send(NetworkEvent::Error(format!( - "Telegram transfer task failed: {error}" - ))) - .await - .ok(); - } - Some(Err(_)) | None => {} + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + }; + if matches!(update, Err(InvocationError::Dropped)) { + bail!("Telegram update stream closed"); + } + match update { + Ok((batch, state)) => { + for update in batch { + Box::pin(process_update(Ok(update), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; } + events.send(NetworkEvent::SyncCheckpoint(crate::cache::SyncCursor { + pts: state.pts, qts: state.qts, date: state.date, seq: state.seq, + channels: state.channels.into_iter().map(|channel| (channel.id, channel.pts)).collect(), + })).await?; + } + Err(error) => { + Box::pin(process_update(Err(error), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; + tokio::time::sleep(Duration::from_millis(250)).await; } } } - transfers.abort_all(); - while transfers.join_next().await.is_some() {} + requests.shutdown().await; + transfers.shutdown().await; - updates - .sync_update_state() - .await - .map_err(anyhow::Error::from_boxed)?; Ok(()) }) .await; handle.quit(); - let _ = pool_task.await; + let _ = pool_tasks.join_next().await; result } -#[allow(clippy::too_many_lines)] +fn signal_active_channel( + cache: &WorkerCache, + chat_id: Option, + sender: &tokio::sync::watch::Sender>, +) { + let value = chat_id.and_then(|id| { + let pts = *cache.channel_pts.get(&id)?; + let peer = cache.peers.get(&id)?; + (peer.id.kind() == PeerKind::Channel).then(|| (peer.id.bare_id_unchecked(), pts)) + }); + sender.send_if_modified(|current| { + if *current == value { + false + } else { + *current = value; + true + } + }); +} + +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] async fn process_update( update: Result, + session: &SqliteSession, client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, recovering: &mut bool, + requests: &mut JoinSet, ) -> Result<()> { match update { Ok(Update::NewMessage(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + if message.mentioned() { + cache.dialogs.dirty = true; + } + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { return Ok(()); } let chat_id = peer_id(&message)?; + if !cache.peers.contains_key(&chat_id) { + cache.dialogs.dirty = true; + } if let Some(peer) = message .peer_ref() .await @@ -383,7 +451,7 @@ async fn process_update( } let message_id = message.id(); let after_dialog_snapshot = advance_dialog_watermark(cache, chat_id, message_id); - let message = Box::pin(map_message(client, &message, cache)).await?; + let message = map_message(&message, cache)?; let event = if after_dialog_snapshot { NetworkEvent::NewMessage(message) } else { @@ -394,23 +462,30 @@ async fn process_update( Ok(Update::MessageEdited(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + cache.message_pins.dirty |= message.pinned(); + if message.mentioned() { + cache.dialogs.dirty = true; + } + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { return Ok(()); } events - .send(NetworkEvent::MessageUpdated( - Box::pin(map_message(client, &message, cache)).await?, - )) + .send(NetworkEvent::MessageUpdated(map_message(&message, cache)?)) .await .ok(); } Ok(Update::MessageDeleted(update)) => { + cache.dialogs.dirty = true; + cache.message_pins.dirty = true; restore_online_status(events, recovering).await; let channel_id = update .channel_id() @@ -427,6 +502,101 @@ async fn process_update( Ok(Update::Raw(update)) => { restore_online_status(events, recovering).await; match &update.raw { + tl::enums::Update::PinnedMessages(update) => { + if let Some(chat_id) = PeerId::from(update.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: update.messages.clone(), + pinned: update.pinned, + }) + .await?; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + } + } + tl::enums::Update::PinnedChannelMessages(update) => { + let chat_id = + PeerId::channel_unchecked(update.channel_id).bot_api_dialog_id_unchecked(); + events + .send(NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: update.messages.clone(), + pinned: update.pinned, + }) + .await?; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + } + tl::enums::Update::DialogPinned(_) | tl::enums::Update::PinnedDialogs(_) => { + cache.dialog_pins.dirty = true; + } + tl::enums::Update::DialogFilter(_) + | tl::enums::Update::DialogFilterOrder(_) + | tl::enums::Update::DialogFilters => { + cache.folders.dirty = true; + } + tl::enums::Update::NotifySettings(_) + | tl::enums::Update::DialogUnreadMark(_) + | tl::enums::Update::PeerSettings(_) + | tl::enums::Update::ReadMessagesContents(_) + | tl::enums::Update::ChannelReadMessagesContents(_) => { + cache.dialogs.dirty = true; + } + tl::enums::Update::FolderPeers(update) => { + cache.dialogs.dirty = true; + cache.dialog_pins.dirty = true; + for peer in &update.folder_peers { + let tl::enums::FolderPeer::Peer(peer) = peer; + if let Some(chat_id) = PeerId::from(peer.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::ArchiveChanged { + chat_id, + archived: peer.folder_id == 1, + }) + .await?; + } + } + } + tl::enums::Update::ReadHistoryInbox(read) => { + if let Some(chat_id) = PeerId::from(read.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::UnreadChanged { + chat_id, + unread: u32::try_from(read.still_unread_count.max(0)) + .unwrap_or_default(), + }) + .await?; + } + } + tl::enums::Update::ReadChannelInbox(read) => { + events + .send(NetworkEvent::UnreadChanged { + chat_id: PeerId::channel_unchecked(read.channel_id) + .bot_api_dialog_id_unchecked(), + unread: u32::try_from(read.still_unread_count.max(0)) + .unwrap_or_default(), + }) + .await?; + } + tl::enums::Update::PtsChanged => { + cache.dialogs.dirty = true; + cache.folders.dirty = true; + cache.dialog_pins.dirty = true; + cache.message_pins.dirty = true; + events + .send(NetworkEvent::CacheInvalidated { chat_id: None }) + .await?; + } + tl::enums::Update::ChannelTooLong(update) => { + let chat_id = + PeerId::channel_unchecked(update.channel_id).bot_api_dialog_id_unchecked(); + cache.dialogs.dirty = true; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + events + .send(NetworkEvent::CacheInvalidated { + chat_id: Some(chat_id), + }) + .await?; + } grammers_client::tl::enums::Update::ReadHistoryOutbox(read) => { if let Some(chat_id) = PeerId::from(read.peer.clone()).bot_api_dialog_id() { cache @@ -1039,18 +1209,17 @@ async fn cancel_authorized_login(client: &Client) -> Result { Ok(CodeOutcome::Restart) } -async fn load_dialogs( - client: &Client, +fn apply_dialogs( + dialogs: Vec, + defaults: (i64, i64), cache: &mut WorkerCache, - events: &mpsc::Sender, -) -> Result<()> { - let mut iter = client.iter_dialogs(); +) -> Result> { let mut chats = Vec::new(); let mut visible_chat_ids = HashSet::new(); let mut dialog_name_ids = HashSet::new(); let mut hidden_broadcasts = HashSet::new(); let mut visible_channel_groups = HashSet::new(); - while let Some(dialog) = iter.next().await? { + for dialog in dialogs { if matches!(&dialog.raw, RawDialog::Folder(_)) { continue; } @@ -1077,6 +1246,9 @@ async fn load_dialogs( let RawDialog::Dialog(raw) = &dialog.raw else { unreachable!("folder placeholders are filtered above") }; + if let Some(pts) = raw.pts { + cache.channel_pts.insert(id, pts); + } let (unread, unread_mark, top_message, read_outbox) = ( u32::try_from(raw.unread_count.max(0)).unwrap_or(u32::MAX), raw.unread_mark, @@ -1095,6 +1267,7 @@ async fn load_dialogs( .or_insert(read_outbox); let last_message = dialog.last_message.as_ref(); chats.push(Chat { + membership: folders::membership(&dialog, defaults), id, title: safe_name(dialog.peer().name(), "Unknown"), kind: match dialog.peer() { @@ -1103,6 +1276,7 @@ async fn load_dialogs( Peer::Channel(_) => unreachable!("broadcast channels are filtered above"), }, unread: unread.max(u32::from(unread_mark)), + last_message_id: (top_message > 0).then_some(top_message), last_message: last_message.map(message_preview).unwrap_or_default(), last_activity: last_message.map(TelegramMessage::date), }); @@ -1114,8 +1288,7 @@ async fn load_dialogs( hidden_broadcasts, visible_channel_groups, ); - events.send(NetworkEvent::Dialogs(chats)).await?; - Ok(()) + Ok(chats) } fn reconcile_dialog_snapshot( @@ -1162,152 +1335,57 @@ async fn handle_command( cache: &mut WorkerCache, events: &mpsc::Sender, transfers: &mut JoinSet, + requests: &mut JoinSet, ) -> Result { match command { - TelegramCommand::LoadHistory { - chat_id, - request_id, - } => { - let result: Result> = async { - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut iter = client.iter_messages(peer).limit(HISTORY_LIMIT); - let mut messages = Vec::new(); - while let Some(message) = iter.next().await? { - messages.push(Box::pin(map_message(client, &message, cache)).await?); - } - messages.reverse(); - hydrate_reply_senders(&mut messages, cache); - Ok(messages) + command @ (TelegramCommand::LoadOlder { .. } + | TelegramCommand::ChangeDialogPin { .. } + | TelegramCommand::SetArchived { .. } + | TelegramCommand::LoadPinnedMessages { .. } + | TelegramCommand::LoadPinnedContext { .. } + | TelegramCommand::ChangeMessagePin { .. } + | TelegramCommand::LoadHistory { .. } + | TelegramCommand::LoadMessage { .. } + | TelegramCommand::SendMessage { .. } + | TelegramCommand::ResolveTelegramLink { .. } + | TelegramCommand::ActivateButton { .. } + | TelegramCommand::MarkRead { .. }) => { + if let TelegramCommand::LoadPinnedMessages { + chat_id, + request_id, + .. } - .await; - match result { - Ok(messages) => { - events - .send(NetworkEvent::History { - chat_id, - request_id, - messages, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::HistoryFailed { - chat_id, - request_id, - error: format!("Could not load history: {error:#}"), - }) - .await?; - } + | TelegramCommand::LoadPinnedContext { + chat_id, + request_id, + .. + } = &command + { + events + .send(NetworkEvent::PinnedMessagesLoading { + chat_id: *chat_id, + request_id: *request_id, + }) + .await?; } - } - TelegramCommand::LoadMessage { - chat_id, - source_message_id, - message_id, - request_id, - } => { - let result: Result = async { - if source_message_id <= 0 || message_id <= 0 { - bail!("invalid Telegram message identifier") - } - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut messages = client - .get_messages_by_id(peer, &[source_message_id]) - .await - .context("could not retrieve the replying message")?; - let source = messages - .pop() - .flatten() - .context("replying message is unavailable")?; - if source.reply_to_message_id() != Some(message_id) { - bail!("reply relation changed before navigation") - } - let telegram_message = client - .get_reply_to_message(&source) - .await - .context("could not retrieve reply target")? - .context("reply target is unavailable")?; - let mut message = Box::pin(map_message(client, &telegram_message, cache)).await?; - hydrate_reply_sender(&mut message, cache); - Ok(message) + if let TelegramCommand::LoadHistory { + chat_id, + request_id, } - .await; - match result { - Ok(message) => { - events - .send(NetworkEvent::MessageLoaded { - chat_id, - message_id, - request_id, - message, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::MessageLoadFailed { - chat_id, - message_id, - request_id, - error: format!("Could not load message: {error:#}"), - }) - .await?; - } - } - } - TelegramCommand::SendMessage { - chat_id, - local_id, - text, - reply_to, - } => { - let Some(peer) = cache.peers.get(&chat_id).copied() else { + | TelegramCommand::LoadOlder { + chat_id, + request_id, + .. + } = &command + { events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: "conversation is missing its Telegram peer reference".to_owned(), + .send(NetworkEvent::HistoryLoading { + chat_id: *chat_id, + request_id: *request_id, }) .await?; - return Ok(false); - }; - let input = InputMessage::new().text(text.clone()).reply_to(reply_to); - match Box::pin(client.send_message(peer, input)).await { - Ok(message) => { - if message.id() <= 0 { - events - .send(NetworkEvent::MessageAccepted { chat_id, local_id }) - .await?; - } else { - events - .send(NetworkEvent::MessageSent { - local_id, - message: Box::pin(map_message(client, &message, cache)).await?, - }) - .await?; - } - } - Err(error) => { - events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: error.to_string(), - }) - .await?; - } } + requests::spawn(command, client, cache, requests); } TelegramCommand::SendAttachment { chat_id, @@ -1346,7 +1424,9 @@ async fn handle_command( return Ok(false); } let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); transfers.spawn(async move { + let _cache_owner = cache_owner.clone(); let result = upload_attachment(&client, peer, &path, &caption, as_photo, reply_to) .await .map_err(|error| format!("{error:#}")); @@ -1383,14 +1463,30 @@ async fn handle_command( match preparation { Ok((peer, directory)) => { let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); transfers.spawn(async move { + let _cache_owner = cache_owner.clone(); let result = if thumbnail { download_preview_thumbnail( - &client, peer, chat_id, message_id, directory, + &client, + peer, + chat_id, + message_id, + directory, + cache_owner, ) .await } else { - download_attachment(&client, peer, chat_id, message_id, directory).await + download_attachment( + &client, + peer, + chat_id, + message_id, + directory, + None, + cache_owner, + ) + .await } .map_err(|error| format!("{error:#}")); TransferCompletion::Preview { @@ -1416,10 +1512,13 @@ async fn handle_command( TelegramCommand::DownloadAttachment { chat_id, message_id, + request_id, + media_id, } => { let Some(peer) = cache.peers.get(&chat_id).copied() else { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error: "conversation is missing its Telegram peer reference".to_owned(), @@ -1430,6 +1529,7 @@ async fn handle_command( if transfers.len() >= MAX_CONCURRENT_TRANSFERS { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error: "too many Telegram transfers are already running".to_owned(), @@ -1439,85 +1539,42 @@ async fn handle_command( } let directory = ensure_download_dir(cache).await?; let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); transfers.spawn(async move { - let result = download_attachment(&client, peer, chat_id, message_id, directory) - .await - .map_err(|error| format!("{error:#}")); + let _cache_owner = cache_owner.clone(); + let result = download_attachment( + &client, + peer, + chat_id, + message_id, + directory, + media_id, + cache_owner, + ) + .await + .map_err(|error| format!("{error:#}")); TransferCompletion::Download { + request_id, chat_id, message_id, result, } }); } - TelegramCommand::ResolveTelegramLink { url } => { - match Box::pin(resolve_telegram_link(client, cache, &url)).await { - Ok((chat, message)) => { - events - .send(NetworkEvent::LinkResolved { chat, message }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::LinkFailed { - url, - error: format!("Could not open Telegram link: {error:#}"), - }) - .await?; - } - } + TelegramCommand::SearchCached(_) + | TelegramCommand::CancelSearch + | TelegramCommand::LoadCachedContext { .. } => { + unreachable!("local searches never reach Telegram") } - TelegramCommand::ActivateButton { - chat_id, - message_id, - button_index, - } => match activate_inline_button(client, cache, chat_id, message_id, button_index).await { - Ok((message, url)) => { - events - .send(NetworkEvent::ButtonActivated { - chat_id, - message_id, - message, - url, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::ButtonFailed { - chat_id, - message_id, - error: format!("Could not activate button: {error:#}"), - }) - .await?; - } - }, - TelegramCommand::MarkRead { chat_id } => { - let result = match cache.peers.get(&chat_id).copied() { - Some(peer) => client.mark_as_read(peer).await, - None => Err(InvocationError::Dropped), - }; - match result { - Ok(()) => events.send(NetworkEvent::ReadMarked { chat_id }).await?, - Err(error) => { - events - .send(NetworkEvent::ReadMarkFailed { - chat_id, - error: format!("Could not mark conversation read: {error}"), - }) - .await?; - } - } + TelegramCommand::RefreshFolders => { + cache.folders.dirty = true; } TelegramCommand::RefreshDialogs => { - if let Err(error) = load_dialogs(client, cache, events).await { - events - .send(NetworkEvent::DialogsFailed(format!( - "Could not refresh conversations: {error:#}" - ))) - .await?; - } + cache.dialog_pins.dirty = true; + cache.message_pins.dirty = true; + requests::refresh_dialogs(client, cache, events, requests).await?; } + TelegramCommand::RefreshDialogPins => cache.dialog_pins.dirty = true, TelegramCommand::Shutdown => return Ok(true), TelegramCommand::StartQrAuth | TelegramCommand::SubmitPhone(_) @@ -1531,18 +1588,13 @@ async fn handle_command( async fn activate_inline_button( client: &Client, - cache: &WorkerCache, - chat_id: ChatId, + peer: PeerRef, message_id: i32, button_index: u16, ) -> Result<(Option, Option)> { if message_id <= 0 { bail!("invalid Telegram message identifier") } - let peer = *cache - .peers - .get(&chat_id) - .context("conversation is missing its Telegram peer reference")?; let mut messages = client .get_messages_by_id(peer, &[message_id]) .await @@ -1625,7 +1677,6 @@ async fn upload_attachment( async fn process_transfer_completion( completion: TransferCompletion, - client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, ) -> Result<()> { @@ -1671,7 +1722,7 @@ async fn process_transfer_completion( events .send(NetworkEvent::MessageSent { local_id, - message: Box::pin(map_message(client, &message, cache)).await?, + message: map_message(&message, cache)?, }) .await?; } @@ -1690,6 +1741,7 @@ async fn process_transfer_completion( } }, TransferCompletion::Download { + request_id, chat_id, message_id, result, @@ -1697,6 +1749,7 @@ async fn process_transfer_completion( Ok(path) => { events .send(NetworkEvent::AttachmentDownloaded { + request_id, chat_id, message_id, path, @@ -1706,6 +1759,7 @@ async fn process_transfer_completion( Err(error) => { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error: format!("Could not download attachment: {error}"), @@ -1723,6 +1777,8 @@ async fn download_attachment( chat_id: ChatId, message_id: i32, directory: PathBuf, + expected_media_id: Option, + cache_owner: Option>, ) -> Result { let mut messages = client .get_messages_by_id(peer, &[message_id]) @@ -1742,17 +1798,30 @@ async fn download_attachment( bail!("this type of Telegram media is not downloadable") } + if expected_media_id.is_some() + && attachment_from_media(&media).and_then(|attachment| attachment.source_id) + != expected_media_id + { + bail!("Attachment changed; refresh the message before opening it"); + } let suggested_name = attachment_from_media(&media).map_or_else( || "attachment".to_owned(), |attachment| attachment.display_name().to_owned(), ); - let path = unique_download_path(&directory, chat_id, message_id, &suggested_name).await?; - let partial = PartialDownload::new(path); + let partial = media_cache::temporary(&directory)?; client - .download_media(&media, &partial.path) + .download_media(&media, partial.as_ref() as &Path) .await .context("Telegram media transfer failed")?; - Ok(partial.finish()) + media_cache::finish( + partial, + directory, + chat_id, + message_id, + &suggested_name, + cache_owner, + ) + .await } /// Use the SDK's Downloadable thumbnail implementation, including cached and @@ -1763,6 +1832,7 @@ async fn download_preview_thumbnail( chat_id: ChatId, message_id: i32, directory: PathBuf, + cache_owner: Option>, ) -> Result { let message = client .get_messages_by_id(peer, &[message_id]) @@ -1783,46 +1853,36 @@ async fn download_preview_thumbnail( .filter(|thumb| thumb.size() > 0) .max_by_key(PhotoSize::size) .context("Telegram did not provide a static thumbnail for this sticker")?; - let path = unique_download_path(&directory, chat_id, message_id, "preview.jpg").await?; - let partial = PartialDownload::new(path); + let partial = media_cache::temporary(&directory)?; client - .download_media(&thumbnail, &partial.path) + .download_media(&thumbnail, partial.as_ref() as &Path) .await .context("Telegram preview transfer failed")?; - Ok(partial.finish()) + media_cache::finish( + partial, + directory, + chat_id, + message_id, + "preview.jpg", + cache_owner, + ) + .await } async fn ensure_download_dir(cache: &mut WorkerCache) -> Result { if let Some(path) = &cache.download_dir { return Ok(path.clone()); } - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let root = std::env::temp_dir().join(format!("termgram-{}-{nonce}", std::process::id())); - tokio::fs::create_dir(&root) - .await - .with_context(|| format!("could not create temporary directory {}", root.display()))?; - protect_download_dir(&root).await?; + let root = media_cache::prepare( + cache.media_root.clone(), + cache.self_id, + cache.cache_owner.clone(), + ) + .await?; cache.download_dir = Some(root.clone()); Ok(root) } -#[cfg(unix)] -async fn protect_download_dir(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) - .await - .with_context(|| format!("could not protect temporary directory {}", path.display())) -} - -#[cfg(not(unix))] -async fn protect_download_dir(_path: &Path) -> Result<()> { - Ok(()) -} - async fn unique_download_path( directory: &Path, chat_id: ChatId, @@ -1930,9 +1990,9 @@ fn is_windows_reserved_base(base: &str) -> bool { async fn resolve_telegram_link( client: &Client, - cache: &mut WorkerCache, + private_link: Option<(PeerRef, String)>, url: &str, -) -> Result<(Chat, Option)> { +) -> Result<(Chat, PeerRef, Option)> { let target = parse_telegram_link(url)?; let (chat, peer, message_id) = match target { TelegramLink::Public { @@ -1949,7 +2009,6 @@ async fn resolve_telegram_link( .bot_api_dialog_id() .context("resolved chat has no stable identifier")?; if matches!(&resolved, Peer::Channel(_)) { - cache.hidden_broadcasts.insert(id); bail!("broadcast channels are outside Termgram's messaging scope") } let peer = resolved @@ -1957,13 +2016,8 @@ async fn resolve_telegram_link( .await .map_err(anyhow::Error::from_boxed)? .context("Telegram did not provide an addressable peer reference")?; - cache.peers.insert(id, peer); - cache.linked_peers.insert(id); - cache_sender_name(cache, resolved.id(), safe_name(resolved.name(), "Unknown")); - if matches!(&resolved, Peer::Group(_)) && resolved.id().kind() == PeerKind::Channel { - cache.visible_channel_groups.insert(id); - } let chat = Chat { + membership: crate::folders::ChatMembership::default(), id, title: safe_name(resolved.name(), "Unknown"), kind: match &resolved { @@ -1972,6 +2026,7 @@ async fn resolve_telegram_link( Peer::Channel(_) => unreachable!("broadcast channels are rejected above"), }, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }; @@ -1981,28 +2036,19 @@ async fn resolve_telegram_link( chat_id, message_id, } => { - let peer = *cache.peers.get(&chat_id).context( + let (peer, title) = private_link.context( "private message links can only open groups already present in your conversations", )?; - if cache.hidden_broadcasts.contains(&chat_id) - || !cache.visible_channel_groups.contains(&chat_id) - { - bail!("private link does not target a visible group conversation") - } - let peer_id = peer.id; let chat = Chat { + membership: crate::folders::ChatMembership::default(), id: chat_id, - title: cache - .names - .get(&peer_id) - .cloned() - .unwrap_or_else(|| "Unknown".to_owned()), + title, kind: ChatKind::Group, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }; - cache.linked_peers.insert(chat_id); (chat, peer, Some(message_id)) } }; @@ -2016,11 +2062,11 @@ async fn resolve_telegram_link( .pop() .flatten() .context("linked message is unavailable")?; - Some(Box::pin(map_message(client, &telegram_message, cache)).await?) + Some(telegram_message) } else { None }; - Ok((chat, message)) + Ok((chat, peer, message)) } fn parse_telegram_link(value: &str) -> Result { @@ -2144,11 +2190,7 @@ fn private_chat_id(value: &str) -> Result { .context("private Telegram channel identifier is too large") } -async fn map_message( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> Result { +fn map_message(message: &TelegramMessage, cache: &mut WorkerCache) -> Result { let chat_id = peer_id(message)?; let media = message.media(); let (sender, reply_sender) = if message.outgoing() { @@ -2156,11 +2198,12 @@ async fn map_message( let reply_sender = username_or_sender(message.sender().and_then(Peer::username), &sender); (sender, reply_sender) } else { - resolve_sender(client, message, cache).await + resolve_sender(message, cache) }; let reply_to = reply_info(message, chat_id, cache); cache_message_sender(cache, chat_id, message.id(), reply_sender); Ok(Message { + pinned: message.pinned(), id: message.id(), chat_id, sender, @@ -2246,11 +2289,7 @@ fn cache_message_sender(cache: &mut WorkerCache, chat_id: ChatId, message_id: i3 } } -async fn resolve_sender( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> (String, String) { +fn resolve_sender(message: &TelegramMessage, cache: &mut WorkerCache) -> (String, String) { let Some(sender_id) = message.sender_id() else { return ("Unknown".to_owned(), "Unknown".to_owned()); }; @@ -2263,16 +2302,9 @@ async fn resolve_sender( if let Some(name) = cache.names.get(&sender_id) { return (name.clone(), name.clone()); } - let Ok(Some(sender)) = message.sender_ref().await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let Ok(peer) = client.resolve_peer(sender).await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let name = safe_name(peer.name(), "Unknown"); - let reply_sender = username_or_sender(peer.username(), &name); - cache_sender_name(cache, sender_id, name.clone()); - (name, reply_sender) + // Short updates may omit the sender object. Never make a network round + // trip on the update path just to obtain a display name. + ("Unknown".to_owned(), "Unknown".to_owned()) } fn username_or_sender(username: Option<&str>, sender: &str) -> String { @@ -2317,7 +2349,7 @@ fn begin_unresolved_refresh(cache: &mut WorkerCache) -> bool { } async fn is_hidden_broadcast( - client: &Client, + session: &SqliteSession, message: &TelegramMessage, cache: &mut WorkerCache, ) -> Result> { @@ -2348,29 +2380,20 @@ async fn is_hidden_broadcast( return Ok(Some(false)); } - let Some(peer_ref) = message - .peer_ref() - .await - .map_err(anyhow::Error::from_boxed)? - else { - // Unknown channel-shaped peers may be either broadcasts or megagroups. - // Drop this update safely, but do not poison either classification cache. - return Ok(None); - }; - match client.resolve_peer(peer_ref).await { - Ok(Peer::Group(group)) => { - cache.peers.insert(chat_id, peer_ref); - cache_sender_name(cache, group.id(), safe_name(group.title(), "Unknown")); - cache.visible_channel_groups.insert(chat_id); - Ok(Some(false)) - } - Ok(Peer::Channel(_)) => { + let info = session.peer(message.peer_id()).await?; + match info { + Some(PeerInfo::Channel { + kind: Some(ChannelKind::Broadcast), + .. + }) => { cache.hidden_broadcasts.insert(chat_id); - cache.visible_channel_groups.remove(&chat_id); Ok(Some(true)) } - Ok(Peer::User(_)) => Ok(Some(false)), - Err(_) => Ok(None), + Some(PeerInfo::Channel { kind: Some(_), .. }) => { + cache.visible_channel_groups.insert(chat_id); + Ok(Some(false)) + } + _ => Ok(None), } } @@ -2538,6 +2561,7 @@ fn message_preview_with_media(text: &str, media: Option<&Media>) -> String { fn attachment_from_media(media: &Media) -> Option { match media { Media::Photo(photo) => Some(Attachment { + source_id: Some(photo.id()), kind: AttachmentKind::Photo, file_name: Some("photo.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2552,6 +2576,7 @@ fn attachment_from_media(media: &Media) -> Option { _ => AttachmentKind::File, }; Some(Attachment { + source_id: Some(document.id()), kind, file_name: safe_media_name(document.name()), mime_type, @@ -2560,6 +2585,7 @@ fn attachment_from_media(media: &Media) -> Option { }) } Media::Sticker(sticker) => Some(Attachment { + source_id: Some(sticker.document.id()), kind: AttachmentKind::Sticker, file_name: safe_media_name(sticker.document.name()) .or_else(|| Some(default_sticker_name(sticker.document.mime_type()).to_owned())), @@ -2829,6 +2855,7 @@ mod tests { let mut cache = WorkerCache::default(); cache_message_sender(&mut cache, 7, 41, "Alice".to_owned()); let mut message = Message { + pinned: false, id: 42, chat_id: 7, sender: "Bob".to_owned(), @@ -2888,6 +2915,7 @@ mod tests { let mut cache = WorkerCache::default(); cache_message_sender(&mut cache, 8, 41, "Other chat".to_owned()); let mut message = Message { + pinned: false, id: 42, chat_id: 7, sender: "Bob".to_owned(), diff --git a/src/telegram/pins.rs b/src/telegram/pins.rs new file mode 100644 index 0000000..6c9a772 --- /dev/null +++ b/src/telegram/pins.rs @@ -0,0 +1,292 @@ +//! Native pin RPC adapters. Filter edits start from fresh, complete server data. + +use anyhow::{Context, Result, ensure}; +use grammers_client::{ + Client, + peer::{Peer, User}, + tl, +}; +use grammers_session::types::{PeerId, PeerRef}; + +use crate::pins::{DialogAction, DialogPins, DialogScope, move_pin}; + +pub(super) async fn change_message( + client: &Client, + peer: PeerRef, + id: i32, + action: crate::pins::MessageAction, +) -> Result<()> { + use crate::pins::MessageAction; + match action { + MessageAction::Pin { notify, only_self } => { + ensure!(id > 0, "Only sent messages can be pinned"); + client + .invoke(&tl::functions::messages::UpdatePinnedMessage { + silent: !notify, + unpin: false, + pm_oneside: only_self, + peer: peer.into(), + id, + }) + .await?; + } + MessageAction::Unpin => { + client.unpin_message(peer, id).await?; + } + MessageAction::UnpinAll => { + // The server can split large unpin operations into several batches. + loop { + let tl::enums::messages::AffectedHistory::History(result) = client + .invoke(&tl::functions::messages::UnpinAllMessages { + peer: peer.into(), + top_msg_id: None, + saved_peer_id: None, + }) + .await?; + if result.offset == 0 { + break; + } + } + } + } + Ok(()) +} + +pub(super) async fn load_dialogs(client: &Client) -> Result { + let (main, archive) = tokio::try_join!(get_dialogs(client, 0), get_dialogs(client, 1))?; + Ok(DialogPins { + main: dialog_ids(&main), + archive: dialog_ids(&archive), + }) +} + +async fn get_dialogs(client: &Client, folder_id: i32) -> Result { + let tl::enums::messages::PeerDialogs::Dialogs(dialogs) = client + .invoke(&tl::functions::messages::GetPinnedDialogs { folder_id }) + .await?; + Ok(dialogs) +} + +fn dialog_ids(dialogs: &tl::types::messages::PeerDialogs) -> Vec { + dialogs + .dialogs + .iter() + .filter_map(|dialog| match dialog { + tl::enums::Dialog::Dialog(dialog) => { + PeerId::from(dialog.peer.clone()).bot_api_dialog_id() + } + tl::enums::Dialog::Folder(_) => None, + }) + .collect() +} + +pub(super) async fn change_dialog( + client: &Client, + peer: PeerRef, + scope: DialogScope, + action: DialogAction, + self_id: i64, +) -> Result<()> { + if let DialogScope::Filter(id) = scope { + return change_filter(client, peer, id, action, self_id).await; + } + if let DialogAction::Set(pinned) = action { + ensure!( + client + .invoke(&tl::functions::messages::ToggleDialogPin { + pinned, + peer: tl::types::InputDialogPeer { peer: peer.into() }.into(), + }) + .await?, + "Telegram did not change the chat pin" + ); + return Ok(()); + } + let folder_id = i32::from(scope == DialogScope::Archive); + let dialogs = get_dialogs(client, folder_id).await?; + let ids = dialog_ids(&dialogs); + let position = ids + .iter() + .position(|id| *id == peer.id.bot_api_dialog_id_unchecked()) + .context("This chat is no longer pinned; refresh and try again")?; + let peers = dialogs + .users + .into_iter() + .map(|user| Peer::User(User::from_raw(client, user))) + .chain( + dialogs + .chats + .into_iter() + .map(|chat| Peer::from_raw(client, chat)), + ) + .map(|peer| (peer.id(), peer)) + .collect::>(); + let mut order = Vec::new(); + for dialog in dialogs.dialogs { + match dialog { + tl::enums::Dialog::Dialog(dialog) => { + let id = PeerId::from(dialog.peer); + let reference = peers + .get(&id) + .context("Pinned peer is unavailable")? + .to_ref() + .await + .map_err(anyhow::Error::from_boxed)? + .context("Pinned peer has no access reference")?; + order.push( + tl::types::InputDialogPeer { + peer: reference.into(), + } + .into(), + ); + } + tl::enums::Dialog::Folder(_) => {} + } + } + move_pin(&mut order, position, action); + ensure!( + client + .invoke(&tl::functions::messages::ReorderPinnedDialogs { + force: false, + folder_id, + order, + }) + .await?, + "Telegram did not reorder pinned chats" + ); + Ok(()) +} + +async fn change_filter( + client: &Client, + peer: PeerRef, + id: i32, + action: DialogAction, + self_id: i64, +) -> Result<()> { + let tl::enums::messages::DialogFilters::Filters(filters) = client + .invoke(&tl::functions::messages::GetDialogFilters {}) + .await?; + let mut filter = filters + .filters + .into_iter() + .find(|filter| match filter { + tl::enums::DialogFilter::Filter(filter) => filter.id == id, + tl::enums::DialogFilter::Chatlist(filter) => filter.id == id, + tl::enums::DialogFilter::Default => false, + }) + .context("This Telegram folder no longer exists")?; + edit_filter_pins(&mut filter, peer.into(), action, self_id)?; + ensure!( + client + .invoke(&tl::functions::messages::UpdateDialogFilter { + id, + filter: Some(filter), + }) + .await?, + "Telegram did not update folder pins" + ); + Ok(()) +} + +fn edit_filter_pins( + filter: &mut tl::enums::DialogFilter, + peer: tl::enums::InputPeer, + action: DialogAction, + self_id: i64, +) -> Result<()> { + let (pinned, included) = match filter { + tl::enums::DialogFilter::Filter(filter) => { + (&mut filter.pinned_peers, &mut filter.include_peers) + } + tl::enums::DialogFilter::Chatlist(filter) => { + (&mut filter.pinned_peers, &mut filter.include_peers) + } + tl::enums::DialogFilter::Default => anyhow::bail!("Default folder pins use the dialog API"), + }; + let id = super::folders::peer_id(&peer, self_id); + let position = pinned + .iter() + .position(|candidate| super::folders::peer_id(candidate, self_id) == id); + // Desktop's `always` set is the union of include_peers and pinned_peers. + // Unpinning retains explicit membership; serialization excludes pins from + // include_peers (tdesktop 4d4da471, data/data_chat_filters.cpp). + for candidate in pinned.iter() { + let id = super::folders::peer_id(candidate, self_id); + if !included + .iter() + .any(|peer| super::folders::peer_id(peer, self_id) == id) + { + included.push(candidate.clone()); + } + } + match action { + DialogAction::Set(true) if position.is_none() => { + pinned.insert(0, peer.clone()); + if !included + .iter() + .any(|candidate| super::folders::peer_id(candidate, self_id) == id) + { + included.push(peer); + } + } + DialogAction::Set(false) => { + if let Some(position) = position { + pinned.remove(position); + } + } + DialogAction::Set(true) => {} + _ => move_pin( + pinned, + position.context("This chat is no longer pinned in this folder")?, + action, + ), + } + included.retain(|peer| { + !pinned.iter().any(|candidate| { + super::folders::peer_id(peer, self_id) == super::folders::peer_id(candidate, self_id) + }) + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unpin_keeps_explicit_membership_and_preserves_server_filter_fields() { + let peer = |id| tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: id }); + let original = tl::types::DialogFilter { + contacts: false, + non_contacts: true, + groups: true, + broadcasts: true, + bots: false, + exclude_muted: true, + exclude_read: true, + exclude_archived: true, + title_noanimate: true, + id: 7, + title: tl::types::TextWithEntities { + text: "Work".into(), + entities: vec![], + } + .into(), + emoticon: Some("x".into()), + color: Some(4), + pinned_peers: vec![peer(1), peer(2)], + include_peers: vec![peer(3)], + exclude_peers: vec![peer(4)], + }; + let mut filter: tl::enums::DialogFilter = original.clone().into(); + edit_filter_pins(&mut filter, peer(2), DialogAction::Set(false), 10).unwrap(); + let mut expected = original.clone(); + expected.pinned_peers = vec![peer(1)]; + expected.include_peers = vec![peer(3), peer(2)]; + assert_eq!(filter, expected.into()); + edit_filter_pins(&mut filter, peer(2), DialogAction::Set(true), 10).unwrap(); + edit_filter_pins(&mut filter, peer(2), DialogAction::MoveDown, 10).unwrap(); + assert_eq!(filter, original.into()); + } +} diff --git a/src/telegram/requests.rs b/src/telegram/requests.rs new file mode 100644 index 0000000..2c3ee8b --- /dev/null +++ b/src/telegram/requests.rs @@ -0,0 +1,570 @@ +//! RPC tasks own their arguments, never the worker's mutable state. +//! Results are merged by the same coordinator that consumes live updates. + +use super::{ + HISTORY_LIMIT, TelegramLink, WorkerCache, activate_inline_button, apply_dialogs, + cache_sender_name, hydrate_reply_sender, hydrate_reply_senders, map_message, + parse_telegram_link, resolve_telegram_link, +}; +use crate::{ + event::{NetworkEvent, TelegramCommand}, + model::{Chat, ChatKind}, +}; +use anyhow::{Context, Result, bail}; +use grammers_client::peer::Dialog; +use grammers_client::{ + Client, + message::{InputMessage, Message as TelegramMessage}, +}; +use grammers_session::types::{PeerKind, PeerRef}; +use tokio::{sync::mpsc, task::JoinSet}; + +pub(super) const MAX_REQUESTS: usize = 8; + +pub(super) struct Completion { + command: TelegramCommand, + result: Result, +} + +pub(super) async fn refresh_dialogs( + client: &Client, + cache: &mut WorkerCache, + events: &mpsc::Sender, + requests: &mut JoinSet, +) -> Result<()> { + if cache.dialogs.in_flight { + cache.dialogs.dirty = true; + } else { + cache.dialogs.in_flight = true; + cache.dialogs.dirty = false; + events.send(NetworkEvent::DialogsLoading).await?; + spawn(TelegramCommand::RefreshDialogs, client, cache, requests); + } + Ok(()) +} + +pub(super) async fn refresh_pending( + client: &Client, + cache: &mut WorkerCache, + events: &mpsc::Sender, + requests: &mut JoinSet, +) -> Result<()> { + if cache.dialogs.dirty && !cache.dialogs.in_flight && requests.len() < MAX_REQUESTS { + refresh_dialogs(client, cache, events, requests).await?; + } + if cache.folders.dirty && !cache.folders.in_flight && requests.len() < MAX_REQUESTS { + cache.folders.dirty = false; + cache.folders.in_flight = true; + spawn(TelegramCommand::RefreshFolders, client, cache, requests); + } + if cache.dialog_pins.dirty && !cache.dialog_pins.in_flight && requests.len() < MAX_REQUESTS { + cache.dialog_pins.dirty = false; + cache.dialog_pins.in_flight = true; + spawn(TelegramCommand::RefreshDialogPins, client, cache, requests); + } + if cache.message_pins.dirty + && !cache.message_pins.in_flight + && requests.len() < MAX_REQUESTS + && let Some(chat_id) = cache.active_chat.filter(|id| cache.peers.contains_key(id)) + { + cache.message_pins.dirty = false; + cache.message_pins.in_flight = true; + events + .send(NetworkEvent::PinnedMessagesLoading { + chat_id, + request_id: 0, + }) + .await?; + spawn( + TelegramCommand::LoadPinnedMessages { + chat_id, + before: 0, + request_id: 0, + }, + client, + cache, + requests, + ); + } + Ok(()) +} + +enum Response { + PinnedMessages(Vec, usize), + DialogPins(crate::pins::DialogPins), + Dialogs(Vec, (i64, i64)), + Folders(Vec), + History(Vec), + Message(Box), + Link(Chat, PeerRef, Option>), + Button(Option, Option), + Read, +} + +/// Capture only the peer needed by this request. The complete cache stays with +/// the coordinator, including updates received while the RPC is running. +pub(super) fn spawn( + command: TelegramCommand, + client: &Client, + cache: &WorkerCache, + requests: &mut JoinSet, +) { + let chat_id = match &command { + TelegramCommand::LoadHistory { chat_id, .. } + | TelegramCommand::ChangeDialogPin { chat_id, .. } + | TelegramCommand::SetArchived { chat_id, .. } + | TelegramCommand::LoadPinnedMessages { chat_id, .. } + | TelegramCommand::LoadPinnedContext { chat_id, .. } + | TelegramCommand::ChangeMessagePin { chat_id, .. } + | TelegramCommand::LoadOlder { chat_id, .. } + | TelegramCommand::LoadMessage { chat_id, .. } + | TelegramCommand::SendMessage { chat_id, .. } + | TelegramCommand::ActivateButton { chat_id, .. } + | TelegramCommand::MarkRead { chat_id } => Some(*chat_id), + _ => None, + }; + let peer = chat_id.and_then(|id| cache.peers.get(&id).copied()); + let private_link = match &command { + TelegramCommand::ResolveTelegramLink { url } => match parse_telegram_link(url) { + Ok(TelegramLink::Private { chat_id, .. }) + if cache.visible_channel_groups.contains(&chat_id) => + { + cache.peers.get(&chat_id).copied().map(|peer| { + let title = cache.names.get(&peer.id).cloned().unwrap_or_default(); + (peer, title) + }) + } + _ => None, + }, + _ => None, + }; + let self_id = cache.self_id; + let client = client.clone(); + requests.spawn(async move { + let result = Box::pin(execute(&command, &client, peer, private_link, self_id)).await; + Completion { command, result } + }); +} + +#[allow(clippy::too_many_lines)] +async fn execute( + command: &TelegramCommand, + client: &Client, + peer: Option, + private_link: Option<(PeerRef, String)>, + self_id: i64, +) -> Result { + let peer = || peer.context("conversation is missing its Telegram peer reference"); + match command { + TelegramCommand::LoadPinnedMessages { before, .. } => { + let mut iter = client + .search_messages(peer()?) + .filter(grammers_client::tl::enums::MessagesFilter::InputMessagesFilterPinned) + .offset_id(*before) + .limit(crate::pins::PAGE_SIZE); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + messages.push(message); + } + Ok(Response::PinnedMessages(messages, iter.total().await?)) + } + TelegramCommand::LoadPinnedContext { message_id, .. } => { + let target = client + .get_messages_by_id(peer()?, &[*message_id]) + .await? + .pop() + .flatten() + .context("Pinned message is no longer available")?; + let mut iter = client + .iter_messages(peer()?) + .offset_id(*message_id) + .limit(HISTORY_LIMIT - 1); + let mut messages = vec![target]; + while let Some(message) = iter.next().await? { + messages.push(message); + } + messages.reverse(); + Ok(Response::History(messages)) + } + TelegramCommand::ChangeMessagePin { + message_id, action, .. + } => { + super::pins::change_message(client, peer()?, *message_id, *action).await?; + Ok(Response::Read) + } + TelegramCommand::SetArchived { archived, .. } => { + client + .invoke(&grammers_client::tl::functions::folders::EditPeerFolders { + folder_peers: vec![ + grammers_client::tl::types::InputFolderPeer { + peer: peer()?.into(), + folder_id: i32::from(*archived), + } + .into(), + ], + }) + .await?; + Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )) + } + TelegramCommand::RefreshDialogPins => Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )), + TelegramCommand::ChangeDialogPin { scope, action, .. } => { + super::pins::change_dialog(client, peer()?, *scope, *action, self_id).await?; + if matches!(scope, crate::pins::DialogScope::Filter(_)) { + Ok(Response::Folders( + super::folders::load(client, self_id).await?, + )) + } else { + Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )) + } + } + TelegramCommand::RefreshFolders => Ok(Response::Folders( + super::folders::load(client, self_id).await?, + )), + TelegramCommand::RefreshDialogs => { + let mut iter = client.iter_dialogs(); + let mut dialogs = Vec::new(); + while let Some(dialog) = iter.next().await? { + dialogs.push(dialog); + } + Ok(Response::Dialogs( + dialogs, + super::folders::default_mutes(client).await?, + )) + } + TelegramCommand::LoadHistory { .. } | TelegramCommand::LoadOlder { .. } => { + let before = match command { + TelegramCommand::LoadOlder { before_id, .. } => *before_id, + _ => 0, + }; + let mut iter = client + .iter_messages(peer()?) + .offset_id(before) + .limit(HISTORY_LIMIT); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + messages.push(message); + } + messages.reverse(); + Ok(Response::History(messages)) + } + TelegramCommand::LoadMessage { + source_message_id, + message_id, + .. + } => { + if *source_message_id <= 0 || *message_id <= 0 { + bail!("invalid Telegram message identifier"); + } + let source = client + .get_messages_by_id(peer()?, &[*source_message_id]) + .await? + .pop() + .flatten() + .context("replying message is unavailable")?; + if source.reply_to_message_id() != Some(*message_id) { + bail!("reply relation changed before navigation"); + } + let message = client + .get_reply_to_message(&source) + .await? + .context("reply target is unavailable")?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::SendMessage { text, reply_to, .. } => { + let input = InputMessage::new().text(text.clone()).reply_to(*reply_to); + let message = Box::pin(client.send_message(peer()?, input)).await?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::ResolveTelegramLink { url } => { + let (chat, peer, message) = resolve_telegram_link(client, private_link, url).await?; + Ok(Response::Link(chat, peer, message.map(Box::new))) + } + TelegramCommand::ActivateButton { + message_id, + button_index, + .. + } => { + let (message, url) = + activate_inline_button(client, peer()?, *message_id, *button_index).await?; + Ok(Response::Button(message, url)) + } + TelegramCommand::MarkRead { .. } => { + client.mark_as_read(peer()?).await?; + Ok(Response::Read) + } + _ => unreachable!("only RPC commands are submitted to request tasks"), + } +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn complete( + completion: Completion, + cache: &mut WorkerCache, + events: &mpsc::Sender, +) -> Result<()> { + let Completion { command, result } = completion; + if matches!( + command, + TelegramCommand::LoadPinnedMessages { request_id: 0, .. } + ) { + cache.message_pins.in_flight = false; + if cache.message_pins.dirty { + if let Some(event) = command.failure("Pins changed during refresh".to_owned()) { + events.send(event).await?; + } + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshDialogs) { + cache.dialogs.in_flight = false; + if cache.dialogs.dirty { + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshFolders) { + cache.folders.in_flight = false; + if cache.folders.dirty { + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshDialogPins) { + cache.dialog_pins.in_flight = false; + if cache.dialog_pins.dirty { + return Ok(()); + } + } + let response = match result { + Ok(response) => response, + Err(error) => { + if let Some(event) = command.failure(format!("Telegram request failed: {error:#}")) { + events.send(event).await?; + } + return Ok(()); + } + }; + let event = match (command, response) { + ( + TelegramCommand::ChangeMessagePin { + chat_id, + message_id, + action, + request_id, + }, + Response::Read, + ) => { + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + let event = match action { + crate::pins::MessageAction::UnpinAll => { + NetworkEvent::MessagePinsCleared { chat_id } + } + _ => NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: vec![message_id], + pinned: matches!(action, crate::pins::MessageAction::Pin { .. }), + }, + }; + events.send(event).await?; + NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error: None, + } + } + ( + TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id, + }, + Response::PinnedMessages(raw, total), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + let next = (messages.len() == crate::pins::PAGE_SIZE) + .then(|| messages.last().expect("full page").id); + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page: crate::pins::MessagePage { + messages, + total: Some(total), + before, + next, + }, + } + } + ( + TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::PinnedContext { + chat_id, + message_id, + request_id, + messages, + } + } + ( + TelegramCommand::SetArchived { + chat_id, + archived, + request_id, + }, + Response::DialogPins(pins), + ) => { + cache.dialogs.dirty = true; + cache.dialog_pins.dirty = true; + events + .send(NetworkEvent::ArchiveChanged { chat_id, archived }) + .await?; + events.send(NetworkEvent::DialogPins(pins)).await?; + NetworkEvent::DialogPinFinished { + request_id, + error: None, + } + } + (TelegramCommand::RefreshDialogPins, Response::DialogPins(pins)) => { + NetworkEvent::DialogPins(pins) + } + (TelegramCommand::ChangeDialogPin { request_id, .. }, response) => { + cache.dialog_pins.dirty = true; + cache.folders.dirty = true; + match response { + Response::DialogPins(pins) => events.send(NetworkEvent::DialogPins(pins)).await?, + Response::Folders(folders) => events.send(NetworkEvent::Folders(folders)).await?, + _ => unreachable!("pin mutations return their authoritative server list"), + } + NetworkEvent::DialogPinFinished { + request_id, + error: None, + } + } + (TelegramCommand::RefreshFolders, Response::Folders(folders)) => { + NetworkEvent::Folders(folders) + } + (TelegramCommand::RefreshDialogs, Response::Dialogs(dialogs, defaults)) => { + NetworkEvent::Dialogs(apply_dialogs(dialogs, defaults, cache)?) + } + ( + TelegramCommand::LoadHistory { + chat_id, + request_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::History { + chat_id, + request_id, + messages, + } + } + ( + TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::OlderHistory { + chat_id, + request_id, + before_id, + messages, + } + } + ( + TelegramCommand::LoadMessage { + chat_id, + message_id, + request_id, + .. + }, + Response::Message(raw), + ) => { + let mut message = map_message(&raw, cache)?; + hydrate_reply_sender(&mut message, cache); + NetworkEvent::MessageLoaded { + chat_id, + message_id, + request_id, + message, + } + } + ( + TelegramCommand::SendMessage { + chat_id, local_id, .. + }, + Response::Message(raw), + ) => { + if raw.id() <= 0 { + NetworkEvent::MessageAccepted { chat_id, local_id } + } else { + NetworkEvent::MessageSent { + local_id, + message: map_message(&raw, cache)?, + } + } + } + (TelegramCommand::ResolveTelegramLink { .. }, Response::Link(chat, peer, raw)) => { + cache.peers.insert(chat.id, peer); + cache.linked_peers.insert(chat.id); + cache_sender_name(cache, peer.id, chat.title.clone()); + if chat.kind == ChatKind::Group && peer.id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + let message = raw + .as_ref() + .map(|raw| map_message(raw, cache)) + .transpose()?; + NetworkEvent::LinkResolved { chat, message } + } + ( + TelegramCommand::ActivateButton { + chat_id, + message_id, + .. + }, + Response::Button(message, url), + ) => NetworkEvent::ButtonActivated { + chat_id, + message_id, + message, + url, + }, + (TelegramCommand::MarkRead { chat_id }, Response::Read) => { + NetworkEvent::ReadMarked { chat_id } + } + _ => unreachable!("request and completion kinds always match"), + }; + events.send(event).await?; + Ok(()) +} diff --git a/src/ui/appearance.rs b/src/ui/appearance.rs new file mode 100644 index 0000000..1c7c7e8 --- /dev/null +++ b/src/ui/appearance.rs @@ -0,0 +1,53 @@ +use super::{ + AppState, Clear, Constraint, DANGER, Frame, Layout, Line, List, ListItem, ListState, MUTED, + Modifier, Paragraph, Rect, Span, Style, Wrap, centered, pane_block, +}; +use crate::{appearance::TerminalColor, keymap::Context}; + +pub(super) fn render_colors(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(picker) = &app.color_picker else { + return; + }; + let popup = centered( + area, + area.width.min(62), + area.height.saturating_sub(2).min(24), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" {} ", picker.label), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).split(inner); + let mut items = vec![ListItem::new("Follow configuration")]; + items.extend(TerminalColor::ALL.iter().map(|color| { + ListItem::new(Line::from(vec![ + Span::styled("■ ", Style::default().fg(color.color())), + Span::raw(color.label()), + ])) + })); + let mut state = ListState::default().with_selected(Some(picker.selection)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[0], + &mut state, + ); + let hint = app.status_message.clone().unwrap_or_else(|| { + format!( + "{} apply · {} cancel\nLocal to this Telegram account", + app.keymap.hint(Context::Overlay, "open"), + app.keymap.hint(Context::Overlay, "cancel") + ) + }); + frame.render_widget( + Paragraph::new(hint) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(if app.status_message.is_some() { + DANGER + } else { + MUTED + })), + rows[1], + ); +} diff --git a/src/ui/icons.rs b/src/ui/icons.rs new file mode 100644 index 0000000..aa006ed --- /dev/null +++ b/src/ui/icons.rs @@ -0,0 +1,51 @@ +//! Opt-in glyph selection; Ratatui and unicode-width still own cell layout. +//! +//! Codepoints verified against Nerd Fonts v3.4.0 glyphnames.json: +//! +//! No font assets or rendering backend are embedded. Like Yazi's Icon and +//! Codex's terminal palettes, icons remain text with a readable fallback. + +use crate::model::{AttachmentKind, ChatKind}; + +#[derive(Clone, Copy)] +pub(super) struct Icons(pub bool); + +impl Icons { + pub(super) const fn pin(self) -> &'static str { + if self.0 { "\u{f08d}" } else { "^" } // nf-fa-thumbtack + } + + pub(super) const fn chat(self, kind: ChatKind) -> &'static str { + if !self.0 { + return ""; + } + match kind { + ChatKind::Direct => "\u{f075} ", // nf-fa-comment + ChatKind::Group => "\u{f0c0} ", // nf-fa-users + ChatKind::Channel => "\u{f0a1} ", // nf-fa-bullhorn + } + } + + pub(super) const fn folder(self, id: i32) -> &'static str { + if !self.0 { + "" + } else if id == 1 { + "\u{f187} " // nf-fa-box_archive + } else { + "\u{f07b} " // nf-fa-folder + } + } + + pub(super) const fn attachment(self, kind: AttachmentKind) -> &'static str { + if !self.0 { + return ""; + } + match kind { + AttachmentKind::Photo => "\u{f03e} ", // nf-fa-image + AttachmentKind::File | AttachmentKind::Other => "\u{f15b} ", // nf-fa-file + AttachmentKind::Video => "\u{f03d} ", // nf-fa-video + AttachmentKind::Audio => "\u{f001} ", // nf-fa-music + AttachmentKind::Sticker => "\u{f118} ", // nf-fa-face_smile + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 01bddaa..40eaa42 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,8 @@ +mod appearance; +mod icons; +mod pins; +mod preview; +mod search; use chrono::Local; use qrcode::{Color as QrColor, QrCode}; use ratatui::Frame; @@ -16,6 +21,7 @@ use crate::app::{ use crate::config::DownloadBehavior; use crate::event::ConnectionStatus; use crate::input::TextInput; +use crate::keymap::Context; use crate::model::{AttachmentKind, Delivery, Message, MessageButtonKind}; const ACCENT: Color = Color::Rgb(216, 180, 254); @@ -51,6 +57,27 @@ pub fn render(frame: &mut Frame<'_>, app: &mut AppState) { } } +fn account_controls(app: &AppState, context: Context) -> String { + let hint = |action| app.keymap.hint(context, action); + format!( + "{} quit · {} next · {} add account", + hint("quit"), + hint("next_account"), + hint("add_account") + ) +} + +fn overlay_controls(app: &AppState, action: &str) -> String { + let hint = |action| app.keymap.hint(Context::Overlay, action); + format!( + "{} {action} · {} close\n{}/{} select", + hint("open"), + hint("cancel"), + hint("up"), + hint("down") + ) +} + fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { let spinner = spinner(app.tick); let mut lines = vec![ @@ -61,7 +88,7 @@ fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { Line::from(""), Line::from(format!("{spinner} Connecting to Telegram…")), Line::from(Span::styled( - "F2 next account · F3 add account · Ctrl+C quits", + account_controls(app, Context::Global), Style::default().fg(MUTED), )), ]; @@ -74,6 +101,19 @@ fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { render_centered_notice(frame, area, lines); } +fn auth_controls(app: &AppState, phase: &AuthPhase) -> String { + let hint = |action| app.keymap.hint(Context::Input, action); + format!( + "{} · {}", + if matches!(phase, AuthPhase::Phone) { + format!("{} QR", hint("focus")) + } else { + format!("{} starts over", hint("cancel")) + }, + account_controls(app, Context::Input) + ) +} + fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPhase) { if let AuthPhase::Qr { url } = phase { render_qr_auth(frame, area, app, url); @@ -81,12 +121,9 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh } let width = area.width.min(72); let text_width = width.saturating_sub(4); - let footer = if matches!(phase, AuthPhase::Phone) { - "Tab QR · F2 next account · F3 add account · Ctrl+C quits" - } else { - "Esc starts over · F2 next account · F3 add account · Ctrl+C quits" - }; - let footer_height = wrapped_height(footer, text_width); + let hint = |action| app.keymap.hint(Context::Input, action); + let footer = auth_controls(app, phase); + let footer_height = wrapped_height(&footer, text_width); let status_height = app .status_message .as_deref() @@ -151,6 +188,7 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh .wrap(Wrap { trim: true }), chunks[0], ); + let submit_hint = format!("{} to continue", hint("open")); render_input( frame, chunks[1], @@ -159,7 +197,7 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh if app.auth_is_submitting() { "Submitted" } else { - "Enter to continue" + &submit_hint }, !app.auth_is_submitting(), ); @@ -173,15 +211,19 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh WARNING, ); } - render_notice(frame, chunks[3], footer, MUTED); + render_notice(frame, chunks[3], &footer, MUTED); } fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) { + let display = app.keymap.hint(Context::Input, "focus"); + let cancel = app.keymap.hint(Context::Input, "cancel"); + let accounts = account_controls(app, Context::Input); let Ok(code) = QrCode::new(url.as_bytes()) else { render_qr_unavailable( frame, area, - "Could not render this QR code. Press Esc to use your phone number.", + app, + &format!("Could not render this QR code. {cancel} for phone sign-in."), ); return; }; @@ -196,19 +238,19 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) QrRenderMode::Compatible => ("Full-cell", "compact"), }; let message = format!( - "{mode_name} QR needs at least {qr_width} × {required_height} terminal cells. Resize, press Tab for {alternative} mode, or Esc for phone sign-in." + "{mode_name} QR needs at least {qr_width} × {required_height} terminal cells. Resize, {display} for {alternative} mode, or {cancel} for phone sign-in." ); let message = app .status_message .as_deref() .map_or_else(|| message.clone(), |error| format!("{error}\n\n{message}")); - render_qr_unavailable(frame, area, &message); + render_qr_unavailable(frame, area, app, &message); return; } let (guidance, style) = if let Some(message) = &app.status_message { ( - format!("{message} · Tab display · F2 next · F3 add · Esc phone"), + format!("{message} · {display} display · {cancel} phone · {accounts}"), Style::default().fg(DANGER), ) } else { @@ -218,7 +260,7 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) }; ( format!( - "{} Telegram: Devices→Link Desktop · Tab {alternative} · F2 next · F3 add · Esc phone", + "{} Telegram: Devices→Link Desktop · {display} {alternative} · {cancel} phone · {accounts}", spinner(app.tick), ), Style::default().fg(WARNING).bold(), @@ -230,8 +272,9 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) render_qr_unavailable( frame, area, + app, &format!( - "{guidance}\n\nResize the terminal to show the QR code, or Esc for phone sign-in." + "{guidance}\n\nResize the terminal to show the QR code, or {cancel} for phone sign-in." ), ); return; @@ -260,7 +303,7 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) ); } -fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, message: &str) { +fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, app: &AppState, message: &str) { let popup = centered(area, area.width.min(72), area.height); render_centered_notice( frame, @@ -273,7 +316,10 @@ fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, message: &str) { Line::from(""), Line::from(message), Line::from(""), - Line::from(Span::styled("Ctrl+C quits", Style::default().fg(MUTED))), + Line::from(Span::styled( + format!("{} quit", app.keymap.hint(Context::Input, "quit")), + Style::default().fg(MUTED), + )), ], ); } @@ -400,14 +446,33 @@ fn render_main(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { render_composer(frame, rows[2], app, show_conversation_only || !narrow); render_footer(frame, rows[3], app, narrow); - if app.mode == Mode::Help { - render_help(frame, area); + if app.mode == Mode::Preview { + preview::render(frame, area, app); + } else if app.mode == Mode::Colors { + appearance::render_colors(frame, area, app); + } else if app.mode == Mode::PinnedMessages { + pins::render(frame, area, app); + } else if app.mode == Mode::PinPrompt { + pins::render_prompt(frame, area, app); + } else if app.mode == Mode::Search { + search::render_search(frame, area, app); + } else if app.mode == Mode::Help { + render_help(frame, area, app); } else if app.mode == Mode::Settings { render_settings(frame, area, app); } else if app.mode == Mode::Accounts { render_accounts(frame, area, app); } - if matches!(app.mode, Mode::Help | Mode::Settings | Mode::Accounts) { + if matches!( + app.mode, + Mode::Search + | Mode::Colors + | Mode::Help + | Mode::Settings + | Mode::Accounts + | Mode::PinnedMessages + | Mode::PinPrompt + ) { app.media_slots.clear(); } } @@ -445,14 +510,42 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) { ); } -fn render_chats(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { - app.set_chat_pane_region((area.x, area.right(), area.y, area.bottom())); - let focused = app.focus == Focus::Chats && app.mode != Mode::Compose; +fn chat_list_title(app: &AppState) -> String { let title = match app.mode { Mode::Filter => format!(" Chats · /{} ", app.filter.value()), + _ if app.folders.len() > 1 => { + let index = app + .folders + .iter() + .position(|folder| folder.id == app.folder_id) + .unwrap_or(0); + format!( + " {} {}/{} · {} {} ", + app.folders[index].title, + index + 1, + app.folders.len(), + app.keymap + .hint(crate::keymap::Context::Chats, "folder_previous"), + app.keymap + .hint(crate::keymap::Context::Chats, "folder_next") + ) + } _ => " Chats ".to_owned(), }; - let block = pane_block(title, focused); + format!( + " {}{}", + icons::Icons(app.keymap.nerd_font).folder(app.folder_id), + title.trim_start() + ) +} + +fn render_chats(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + app.set_chat_pane_region((area.x, area.right(), area.y, area.bottom())); + let focused = app.focus == Focus::Chats && app.mode != Mode::Compose; + let title = chat_list_title(app); + let block = pane_block(title, focused).title_style( + Style::default().fg(app.color(crate::appearance::Target::Folder(app.folder_id))), + ); let visible = app.filtered_chat_indices(); let viewport_height = usize::from(area.height.saturating_sub(2)).max(1); let selected = app.selected_chat.min(visible.len().saturating_sub(1)); @@ -472,24 +565,37 @@ fn render_chats(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { } else { String::new() }; - let time = chat.activity_label(now); + let time = if app.chat_pin_position(chat.id).is_some() { + format!( + "{} {}", + icons::Icons(app.keymap.nerd_font).pin(), + chat.activity_label(now) + ) + } else { + chat.activity_label(now) + }; let width = usize::from(area.width.saturating_sub(4)); let suffix_width = UnicodeWidthStr::width(time.as_str()) + UnicodeWidthStr::width(unread.as_str()); let title = truncate_cells( - &chat.title, + &format!( + "{}{}", + icons::Icons(app.keymap.nerd_font).chat(chat.kind), + chat.title + ), width.saturating_sub(suffix_width).saturating_sub(1), ); let gap = width .saturating_sub(UnicodeWidthStr::width(title.as_str())) .saturating_sub(suffix_width) .max(1); + let style = Style::default().fg(app.color(crate::appearance::Target::Chat(chat.id))); let style = if selected { - Style::default().fg(Color::Black).bg(ACCENT).bold() + style.add_modifier(Modifier::REVERSED).bold() } else if chat.unread > 0 { - Style::default().bold() + style.bold() } else { - Style::default() + style }; ListItem::new(Line::from(vec![ Span::styled(title, style), @@ -528,7 +634,7 @@ fn render_chats(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { list.block(block) .highlight_symbol("› ") .highlight_spacing(HighlightSpacing::Always) - .highlight_style(Style::default().fg(Color::Black).bg(ACCENT).bold()), + .highlight_style(Style::default().add_modifier(Modifier::REVERSED).bold()), area, &mut state, ); @@ -544,19 +650,45 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { let block = pane_block( title, app.focus == Focus::Conversation || app.mode == Mode::Compose, + ) + .title_style( + Style::default().fg(app.active_chat_id.map_or(Color::Reset, |id| { + app.color(crate::appearance::Target::Chat(id)) + })), ); - let inner = block.inner(area); + let mut inner = block.inner(area); frame.render_widget(block, area); let Some(chat_id) = app.active_chat_id else { app.set_message_hit_regions(Vec::new()); frame.render_widget( - Paragraph::new("Select a chat and press Enter") - .alignment(Alignment::Center) - .style(Style::default().fg(MUTED)), + Paragraph::new(format!( + "Select a chat · {} open", + app.keymap.hint(Context::Chats, "open") + )) + .alignment(Alignment::Center) + .style(Style::default().fg(MUTED)), vertically_centered(inner, 1), ); return; }; + if let Some((message, count)) = app.pinned_summary() + && inner.height > 1 + { + let label = format!( + "{} {} pins{} · {}", + icons::Icons(app.keymap.nerd_font).pin(), + app.keymap.hint(Context::Conversation, "pins"), + count.map_or(String::new(), |n| format!(" ({n})")), + crate::model::sanitize_terminal_line(&message.text), + ); + frame.render_widget( + Paragraph::new(truncate_cells(&label, usize::from(inner.width))) + .style(Style::default().fg(ACCENT)), + Rect { height: 1, ..inner }, + ); + inner.y = inner.y.saturating_add(1); + inner.height = inner.height.saturating_sub(1); + } let messages: &[Message] = app.messages.get(&chat_id).map_or(&[], Vec::as_slice); if messages.is_empty() { app.set_message_hit_regions(Vec::new()); @@ -566,7 +698,7 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { let text = if app.loading_history { format!("{} Loading messages…", spinner(app.tick)) } else { - "No messages yet — press i to write one".to_owned() + "No messages yet".to_owned() }; frame.render_widget( Paragraph::new(text) @@ -592,6 +724,7 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { app.selected_message == Some(message.id), app.selected_action, app.settings().show_message_ids, + icons::Icons(app.keymap.nerd_font), &actions, ); let body_action = rendered.body_action; @@ -773,25 +906,16 @@ fn render_new_message_badge(frame: &mut Frame<'_>, area: Rect, count: usize) { ); } -fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &AppState, enabled: bool) { +fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &mut AppState, enabled: bool) { + app.set_composer_region((area.x, area.right(), area.y, area.bottom())); let active = app.mode == Mode::Compose; - let title = if !enabled || app.active_chat_id.is_none() { - " Message · select a chat ".to_owned() - } else if let Some(reply) = app.active_reply_target() { - let sender = reply.sender.as_deref().unwrap_or("unknown"); - if active { - format!( - " Reply to #{} {sender} · Enter send · Esc cancel ", - reply.message_id - ) - } else { - format!(" Reply draft to #{} {sender} · i resume ", reply.message_id) - } - } else if active { - " Message · Enter send · Esc keep draft ".to_owned() - } else { - " Message · i to compose ".to_owned() - }; + let title = app.active_reply_target().map_or_else(String::new, |reply| { + format!( + " Reply to #{} {} ", + reply.message_id, + reply.sender.as_deref().unwrap_or("unknown") + ) + }); let block = pane_block(title, active); let inner = block.inner(area); frame.render_widget(block, area); @@ -807,9 +931,26 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &AppState, enabled: b let input = app.active_draft().unwrap_or(&empty); let (row, column) = input_cursor(input, inner.width.max(1)); let vertical_scroll = row.saturating_sub(inner.height.saturating_sub(1)); - let placeholder = input.is_empty() && !active; + let placeholder = input.is_empty(); let text = if placeholder { - Text::from("Write a message…") + use crate::keymap::Context; + let hint = if app.keymap.ghost_text.is_empty() { + String::new() + } else if active { + app.keymap + .ghost_text + .replace("{send}", &app.keymap.hint(Context::Compose, "send")) + .replace("{newline}", &app.keymap.hint(Context::Compose, "newline")) + .replace("{cancel}", &app.keymap.hint(Context::Compose, "cancel")) + } else if app.focus == Focus::Chats { + format!("{} to compose", app.keymap.hint(Context::Chats, "compose")) + } else { + format!( + "{} to compose", + app.keymap.hint(Context::Conversation, "compose") + ) + }; + Text::from(crate::model::sanitize_terminal_line(&hint)) } else { Text::from( editor_lines(input.value(), inner.width.max(1)) @@ -852,21 +993,46 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &AppState, narrow: bool format!(" Update {version} available · run tg update"), Style::default().fg(SUCCESS), )) - } else if app.mode == Mode::Compose { - let prefix = app.active_reply_target().map_or_else(String::new, |reply| { - format!(" Reply #{} · ", reply.message_id) + } else if app.mode != Mode::Navigate { + Line::default() + } else if app.focus == Focus::Conversation && app.selected_message.is_some() { + let hint = |action| app.keymap.hint(Context::Conversation, action); + let media = app.selected_message.is_some_and(|id| { + app.active_messages().iter().any(|message| { + message.id == id + && message + .attachment + .as_ref() + .is_some_and(crate::model::Attachment::supports_preview) + }) }); + let preview = if media { + format!("{} preview · ", hint("preview")) + } else { + String::new() + }; Line::from(format!( - "{prefix} Enter send · drop files to attach · Ctrl+J newline · Esc" + " {preview}{} reply · {} open · {} clear selection", + hint("compose"), + hint("open"), + hint("cancel") )) - } else if app.mode == Mode::Settings { - Line::from(" ↑↓ select · Enter toggle · Esc close settings") - } else if app.mode == Mode::Accounts { - Line::from(" ↑↓ select · Enter switch/add · Esc close accounts") - } else if narrow && app.narrow_conversation { - Line::from(" [/ ] select · R reply · o/O action · r target · Esc chats") } else { - Line::from(" ↑↓/jk move · [/] select · R reply · o/O action · r target · ? help · q quit") + let context = if app.focus == Focus::Chats { + crate::keymap::Context::Chats + } else { + crate::keymap::Context::Conversation + }; + let back = if narrow && app.narrow_conversation { + format!( + "{} chats · ", + app.keymap + .hint(crate::keymap::Context::Conversation, "cancel") + ) + } else { + String::new() + }; + Line::from(format!(" {back}{} help", app.keymap.hint(context, "help"))) }; frame.render_widget( Paragraph::new(content).style(Style::default().fg(MUTED)), @@ -874,48 +1040,32 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &AppState, narrow: bool ); } -fn render_help(frame: &mut Frame<'_>, area: Rect) { +fn render_help(frame: &mut Frame<'_>, area: Rect, app: &AppState) { let popup = centered(area, area.width.min(72), area.height.min(27)); frame.render_widget(Clear, popup); let block = Block::new() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(ACCENT)) - .title(" Keyboard shortcuts "); - let lines = vec![ - Line::from(vec![ - Span::styled("Navigation", Style::default().bold()), - Span::raw(" ↑↓ or j/k · Enter opens"), - ]), - Line::from("Tab switch chats / conversation"), - Line::from("PgUp/PgDn/Home/End scroll / oldest / latest"), - Line::from("o/O + Enter select and activate message actions"), - Line::from("[ / ] select older / newer message (latest first)"), - Line::from("R compose a reply to selected/latest message"), - Line::from("r jump to a selected reply's target"), - Line::from("Right click reply to a message under the pointer"), - Line::from("l follow selected/first link in a message"), - Line::from("/ filter chats (from chat list)"), - Line::from("s / a settings / accounts"), - Line::from("F2 / F3 next / add account"), - Line::from(""), - Line::from(vec![ - Span::styled("Writing", Style::default().bold()), - Span::raw(" i or Enter starts"), - ]), - Line::from("Enter / Ctrl+J send / insert a new line"), - Line::from("Ctrl+A/E/W/U start / end / delete word / clear"), - Line::from("Esc cancel reply, then keep draft and leave"), - Line::from("Drop / paste send existing local file paths"), - Line::from(""), - Line::from("? / Esc close help"), - Line::from("q / Ctrl+C quit"), - ]; + .title(format!( + " Shortcuts · {} close · {}/{} scroll ", + app.keymap.hint(Context::Overlay, "cancel"), + app.keymap.hint(Context::Overlay, "up"), + app.keymap.hint(Context::Overlay, "down") + )); + let lines = app + .keymap + .help() + .into_iter() + .skip(app.help_scroll) + .take(usize::from(popup.height.saturating_sub(2))) + .map(Line::from) + .collect::>(); frame.render_widget( Paragraph::new(lines) .block(block) .wrap(Wrap { trim: false }) - .style(Style::default().fg(Color::White)), + .style(Style::default()), popup, ); } @@ -984,11 +1134,9 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { if index == 2 { lines.push(Line::from(Span::styled( match settings.download_behavior { - DownloadBehavior::TempOnly => { - " Temp download only; Termgram never reveals files." - } + DownloadBehavior::CacheOnly => " Keep downloads for reuse.", DownloadBehavior::RevealOnActivation => { - " Second activation reveals; Termgram never executes files." + " Activate a downloaded file to reveal it." } }, Style::default().fg(MUTED), @@ -999,7 +1147,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { lines.extend([ Line::from(""), Line::from(Span::styled( - "↑↓/j/k select · Enter/Space toggle · Esc close", + overlay_controls(app, "toggle"), Style::default().fg(MUTED), )), ]); @@ -1065,7 +1213,7 @@ fn render_accounts(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { lines.extend([ Line::from(""), Line::from(Span::styled( - "↑↓/j/k select · Enter switch · 1-8 direct · Esc close", + overlay_controls(app, "switch"), Style::default().fg(MUTED), )), ]); @@ -1097,8 +1245,9 @@ fn render_fatal(frame: &mut Frame<'_>, area: Rect, app: &AppState, message: &str Line::from(""), Line::from(Span::styled( format!( - "Account {} · F2 next · F3 add · q/Ctrl+C quit", - app.active_account() + "Account {} · {}", + app.active_account(), + account_controls(app, Context::Chats) ), Style::default().fg(MUTED), )), @@ -1211,6 +1360,7 @@ fn message_lines( selected: bool, selected_action: usize, show_message_ids: bool, + icons: icons::Icons, actions: &[MessageAction], ) -> RenderedMessage { let time = message @@ -1229,6 +1379,12 @@ fn message_lines( .saturating_sub(id_reserve) .max(8); let mut body = message_body(message, attachment_state, download_behavior); + if let Some(attachment) = &message.attachment { + body.insert_str(0, icons.attachment(attachment.kind)); + } + if message.pinned { + body = format!("{} {body}", icons.pin()); + } if let Some(reply) = &message.reply_to { let sender = reply.sender.as_deref().unwrap_or("unknown"); body = format!("↩ #{} {} {body}", reply.message_id, sender); @@ -1444,11 +1600,11 @@ fn message_body( "inline preview" } else { match state { - AttachmentState::Ready => "click/Enter to download", + AttachmentState::Ready => "ready to download", AttachmentState::Downloading => "downloading…", AttachmentState::Downloaded => match download_behavior { - DownloadBehavior::TempOnly => "downloaded to temp", - DownloadBehavior::RevealOnActivation => "click/Enter to reveal", + DownloadBehavior::CacheOnly => "downloaded to cache", + DownloadBehavior::RevealOnActivation => "downloaded · activate to reveal", }, } }; @@ -1682,16 +1838,72 @@ mod tests { populated_app_with_settings(Settings::default()) } + #[test] + fn pinned_overlay_keeps_navigation_visible_in_a_small_terminal() { + let mut app = populated_app(); + app.mode = Mode::PinnedMessages; + app.message_pins.chat = Some(7); + app.message_pins.page.messages = app.active_messages().to_vec(); + let rendered = render_text(&app, 40, 10); + assert!(rendered.contains("Pinned messages")); + assert!(rendered.contains(" open")); + assert!(rendered.contains(" close")); + assert!(rendered.contains(" next")); + } + + #[test] + fn nerd_font_opt_in_preserves_labels_and_plain_font_fallback() { + let mut app = populated_app(); + app.folders.push(crate::folders::Folder::archive()); + app.folder_id = 1; + app.chats[0].membership.archived = true; + app.pins.dialogs.archive = vec![7]; + let message = &mut app.messages.get_mut(&7).unwrap()[0]; + message.pinned = true; + message.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("notes.txt".to_owned()), + mime_type: None, + size: None, + fallback_emoji: None, + }); + assert!(!app.keymap.nerd_font); + let plain = render_text(&app, 100, 24); + assert!(plain.contains("Archive")); + assert!(plain.contains("notes.txt")); + assert!( + !plain + .chars() + .any(|ch| ('\u{e000}'..='\u{f8ff}').contains(&ch)) + ); + app.keymap = crate::keymap::Keymap::parse("return { nerd_font = true }").unwrap(); + let icons = render_text(&app, 100, 24); + for glyph in ['\u{f187}', '\u{f075}', '\u{f08d}', '\u{f15b}'] { + assert!(icons.contains(glyph)); + } + // TestBackend retains the blank continuation cell of each wide glyph. + assert!(icons.contains("Alice 東 京")); + assert!(icons.contains("notes.txt")); + assert!(icons.contains("P pins")); + assert!(render_text(&app, 40, 10).contains("Archive")); + app.keymap = crate::keymap::Keymap::parse("return { nerd_font = false }").unwrap(); + assert_eq!(render_text(&app, 100, 24), plain); + assert!(crate::keymap::Keymap::parse("return { nerd_font = 'yes' }").is_err()); + } + fn populated_app_with_settings(settings: Settings) -> AppState { let mut app = AppState::with_ephemeral_settings(settings); app.screen = Screen::Main; app.connection = ConnectionStatus::Online; app.user_name = Some("Me".to_owned()); app.chats.push(Chat { + membership: crate::folders::ChatMembership::default(), id: 7, title: "Alice 東京".to_owned(), kind: ChatKind::Direct, unread: 2, + last_message_id: None, last_message: "hello from the terminal".to_owned(), last_activity: Some(Utc::now()), }); @@ -1699,6 +1911,7 @@ mod tests { app.messages.insert( 7, vec![Message { + pinned: false, id: 11, chat_id: 7, sender: "Alice".to_owned(), @@ -1735,6 +1948,83 @@ mod tests { .collect() } + #[test] + fn visible_composer_click_enters_input_and_resets_with_the_frame() { + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + + let mut app = populated_app(); + app.active_chat_id = None; + render_text_mut(&mut app, 100, 24); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 10, + row: 21, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.active_chat_id, Some(7)); + assert_eq!(app.mode, Mode::Compose); + app.update(crate::event::AppEvent::Paste("你好🙂".to_owned())); + assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); + app.handle_action(KeyAction::Escape); + render_text_mut(&mut app, 30, 8); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 10, + row: 21, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.mode, Mode::Navigate); + } + + #[test] + fn expanded_preview_fits_small_windows_and_keeps_close_visible_on_error() { + let mut app = populated_app(); + app.messages.get_mut(&7).unwrap()[0].attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: None, + fallback_emoji: None, + }); + app.mode = Mode::Preview; + app.selected_message = Some(11); + app.status_message = Some("Cannot load image. ".repeat(20)); + for (width, height) in [(100, 24), (40, 10)] { + let output = render_text_mut(&mut app, width, height); + assert!(output.contains(" close")); + assert!(output.contains("Cannot load image")); + assert_eq!(app.media_slots.len(), 1); + let slot = &app.media_slots[0]; + assert!(slot.viewport.right() <= width); + assert!(slot.viewport.bottom() < height); + assert_eq!(slot.size.width, width - 2); + } + } + + #[test] + fn composer_hint_tracks_the_effective_send_binding() { + let mut app = populated_app(); + app.mode = Mode::Compose; + app.keymap = crate::keymap::Keymap::parse("return { keymap={{context='compose',on={''},run='newline'}, {context='compose',on={''},run='send'}} }").unwrap(); + let text = render_text(&app, 100, 24); + assert!(text.contains(" to send")); + assert!(!text.contains("Enter send")); + app.mode = Mode::Navigate; + app.focus = Focus::Chats; + assert!(render_text(&app, 100, 24).contains("i to compose")); + } + + #[test] + fn narrow_search_keeps_submit_and_close_controls_visible() { + let mut app = populated_app(); + app.mode = Mode::Search; + app.search.editing = true; + let output = render_text(&app, 40, 14); + assert!(output.contains(" search")); + assert!(output.contains(" close")); + } + #[test] fn login_errors_wrap_and_grow_for_all_phone_auth_phases() { let error = "Could not request a login code: request error: rpc error 500: AUTH_RESTART"; @@ -1754,16 +2044,16 @@ mod tests { output.contains("AUTH_RESTART"), "{width}x{height}: {output}" ); - assert!(output.contains("Ctrl+C quits")); - assert!(output.contains("Enter to continue")); + assert!(output.contains(" quit"), "{output}"); + assert!(output.contains(" to continue")); } app.status_message = Some(format!("{} END_OF_ERROR", "连接失败 retry ".repeat(30))); let output = render_text(&app, 72, 40); assert!(output.contains("END_OF_ERROR")); - assert!(output.contains("Ctrl+C quits")); + assert!(output.contains(" quit"), "{output}"); let small = render_text(&app, 40, 10); assert!(small.contains("Resize terminal for more")); - assert!(small.contains("Ctrl+C quits")); + assert!(small.contains(" quit")); } } @@ -1789,7 +2079,7 @@ mod tests { app.screen = Screen::Fatal(format!("{} END_OF_ERROR", "request failed ".repeat(30))); let output = render_text(&app, 40, 24); assert!(output.contains("END_OF_ERROR")); - assert!(output.contains("q/Ctrl+C")); + assert!(output.contains("q quit")); assert!(output.contains("quit")); assert!(render_text(&app, 40, 10).contains("Resize terminal for more")); } @@ -1865,7 +2155,7 @@ mod tests { let output = render_text(&app, 100, 24); assert!(output.contains("Reply to #11 Alice")); - assert!(output.contains("Reply #11")); + assert_eq!(output.matches("Reply to #11 Alice").count(), 1); } #[test] @@ -1876,11 +2166,11 @@ mod tests { assert!(output.contains("Termgram")); assert!(output.contains("Alice")); assert!(output.contains("hello from the terminal")); - assert!(output.contains("Message · i to compose")); + assert!(output.contains("i to compose")); } #[test] - fn settings_overlay_renders_defaults_and_safety_language() { + fn settings_overlay_renders_current_download_behavior() { let mut app = populated_app(); app.mode = Mode::Settings; let output = render_text(&app, 100, 30); @@ -1889,16 +2179,16 @@ mod tests { assert!(output.contains("Automatic update checks")); assert!(output.contains("Stable")); assert!(output.contains("Reveal on activation")); - assert!(output.contains("never executes files")); + assert!(output.contains("Activate a downloaded file to reveal it")); } #[test] - fn settings_overlay_reflects_prerelease_and_temp_only() { + fn settings_overlay_reflects_prerelease_and_cache_only() { let mut app = AppState::with_settings( Settings { automatic_update_checks: false, release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, + download_behavior: DownloadBehavior::CacheOnly, show_message_ids: false, ..Settings::default() }, @@ -1910,8 +2200,8 @@ mod tests { assert!(output.contains("Off")); assert!(output.contains("Prerelease")); - assert!(output.contains("Temp only")); - assert!(output.contains("never reveals files")); + assert!(output.contains("Keep in cache")); + assert!(output.contains("Keep downloads for reuse")); } #[test] @@ -1930,7 +2220,7 @@ mod tests { assert!(output.contains("Account 2 · Me (active)")); assert!(output.contains("Account 3")); assert!(output.contains("+ Add account")); - assert!(output.contains("1-8 direct")); + assert!(output.contains(" switch")); } #[test] @@ -1981,6 +2271,7 @@ mod tests { let mut app = populated_app(); app.messages.get_mut(&7).unwrap().extend([ Message { + pinned: false, id: 12, chat_id: 7, sender: "Alice".to_owned(), @@ -1990,6 +2281,7 @@ mod tests { outgoing: false, delivery: Delivery::Read, attachment: Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("image.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2000,6 +2292,7 @@ mod tests { buttons: Vec::new(), }, Message { + pinned: false, id: 13, chat_id: 7, sender: "Alice".to_owned(), @@ -2009,6 +2302,7 @@ mod tests { outgoing: false, delivery: Delivery::Read, attachment: Some(Attachment { + source_id: None, kind: AttachmentKind::Sticker, file_name: None, mime_type: None, @@ -2079,7 +2373,7 @@ mod tests { }); let output = render_text(&app, 72, 20); - assert!(output.contains("Esc starts over")); + assert!(output.contains(" starts over")); } #[test] @@ -2127,8 +2421,8 @@ mod tests { let output = render_text(&app, 80, 24); assert!(output.contains("Devices→Link Desktop")); - assert!(output.contains("Tab full")); - assert!(output.contains("Esc phone")); + assert!(output.contains(" full")); + assert!(output.contains(" phone")); assert!(output.contains('▀')); assert!(!output.contains(secret_url)); assert_eq!(qr_pair_symbol(QrColor::Dark, QrColor::Dark), "█"); @@ -2157,7 +2451,7 @@ mod tests { .iter() .map(ratatui::buffer::Cell::symbol) .collect(); - assert!(compatible_text.contains("Tab compact")); + assert!(compatible_text.contains(" compact")); assert!(!compatible_text.contains('▀')); assert!(!compatible_text.contains(secret_url)); assert!( @@ -2185,7 +2479,7 @@ mod tests { let conversation = render_text(&app, 70, 24); assert!(conversation.contains("Alice")); assert!(conversation.contains("hello from the terminal")); - assert!(conversation.contains("Esc chats")); + assert!(conversation.contains(" chats")); } #[test] @@ -2194,6 +2488,7 @@ mod tests { let mut photo = app.messages.get(&7).unwrap()[0].clone(); photo.id = 12; photo.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("photo.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2220,10 +2515,12 @@ mod tests { let mut app = populated_app(); for index in 0_i64..30 { app.chats.push(Chat { + membership: crate::folders::ChatMembership::default(), id: 100 + index, title: format!("Overflow chat {index:02}"), kind: ChatKind::Direct, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }); @@ -2239,6 +2536,7 @@ mod tests { let mut app = populated_app(); let messages = (0_i32..30) .map(|id| Message { + pinned: false, id, chat_id: 7, sender: "Alice".to_owned(), @@ -2266,6 +2564,7 @@ mod tests { .get_mut(&7) .expect("active history") .push(Message { + pinned: false, id: 30, chat_id: 7, sender: "Alice".to_owned(), @@ -2301,6 +2600,7 @@ mod tests { app.messages.insert( 7, vec![Message { + pinned: false, id: 99, chat_id: 7, sender: "Alice".to_owned(), diff --git a/src/ui/pins.rs b/src/ui/pins.rs new file mode 100644 index 0000000..60ebe5e --- /dev/null +++ b/src/ui/pins.rs @@ -0,0 +1,143 @@ +use super::{ + AppState, Clear, Constraint, Frame, Layout, List, ListItem, ListState, MUTED, Modifier, + Paragraph, Rect, Style, Wrap, centered, pane_block, truncate_cells, +}; +use crate::{keymap::Context, model::sanitize_terminal_line}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let popup = centered( + area, + area.width.saturating_sub(4).min(90), + area.height.saturating_sub(2).min(24), + ); + frame.render_widget(Clear, popup); + let state = &app.message_pins; + let count = state + .page + .total + .map_or_else(|| "cached".to_owned(), |total| total.to_string()); + let block = pane_block( + format!( + " Pinned messages · {count} · page {} ", + state.starts.len().max(1) + ), + true, + ); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(inner); + if state.page.messages.is_empty() { + let label = if state.loading { + "Loading pinned messages…" + } else if state.page.total.is_none() { + "No cached pins · connect to synchronize" + } else { + "No pinned messages" + }; + frame.render_widget(Paragraph::new(label).wrap(Wrap { trim: true }), rows[0]); + } else { + let items = state + .page + .messages + .iter() + .map(|message| { + let text = if message.text.is_empty() { + message + .attachment + .as_ref() + .map_or("Message", |attachment| attachment.display_name()) + } else { + &message.text + }; + ListItem::new(truncate_cells( + &sanitize_terminal_line(&format!("#{} {}: {text}", message.id, message.sender)), + usize::from(rows[0].width.saturating_sub(2)), + )) + }) + .collect::>(); + let mut selection = ListState::default().with_selected(Some(state.selected)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[0], + &mut selection, + ); + } + let hint = |action| app.keymap.hint(Context::Pins, action); + let status = state.error.clone().unwrap_or_else(|| { + format!( + "{} unpin · {} unpin all{}", + hint("pin"), + hint("unpin_all"), + if state.loading { " · syncing…" } else { "" } + ) + }); + frame.render_widget( + Paragraph::new(format!( + "{} open · {} close\n{} previous · {} next\n{}", + hint("open"), + hint("cancel"), + hint("pins_previous"), + hint("pins_more"), + truncate_cells(&status, usize::from(rows[1].width)) + )) + .style(Style::default().fg(MUTED)), + rows[1], + ); +} + +pub(super) fn render_prompt(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(prompt) = &app.message_pins.prompt else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(4).min(72), + area.height.saturating_sub(2).min(12), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" {} ", prompt.title), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(inner); + frame.render_widget( + Paragraph::new(truncate_cells(&prompt.preview, usize::from(rows[0].width))) + .style(Style::default().fg(MUTED)), + rows[0], + ); + let items = prompt + .options + .iter() + .map(|(label, _)| ListItem::new(label.as_str())) + .collect::>(); + let mut state = ListState::default().with_selected(Some(prompt.selection)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[1], + &mut state, + ); + let status = app + .message_pins + .error + .as_deref() + .or(app.status_message.as_deref()) + .unwrap_or("Changes apply to Telegram"); + frame.render_widget( + Paragraph::new(format!( + "{} confirm · {} cancel\n{}", + app.keymap.hint(Context::Overlay, "open"), + app.keymap.hint(Context::Overlay, "cancel"), + truncate_cells(status, usize::from(rows[2].width)) + )) + .style(Style::default().fg(MUTED)), + rows[2], + ); +} diff --git a/src/ui/preview.rs b/src/ui/preview.rs new file mode 100644 index 0000000..54d0297 --- /dev/null +++ b/src/ui/preview.rs @@ -0,0 +1,78 @@ +//! Expanded image view using the existing asynchronous media renderer. +use super::{ACCENT, MUTED, WARNING, clamp_u16}; +use crate::{app::AppState, keymap::Context, media::MediaSlot}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect, Size}, + style::Style, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + app.media_slots.clear(); + frame.render_widget(Clear, area); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + let title = app + .selected_message + .and_then(|id| { + app.active_messages() + .iter() + .find(|message| message.id == id) + }) + .and_then(|message| message.attachment.as_ref()) + .map_or_else( + || "Preview".to_owned(), + |attachment| crate::model::sanitize_terminal_line(attachment.display_name()), + ); + let block = Block::new() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)); + let inner = block.inner(rows[0]); + frame.render_widget(block, rows[0]); + let status = + if let (Some(chat_id), Some(message_id)) = (app.active_chat_id, app.selected_message) { + app.media_slots.push(MediaSlot { + chat_id, + message_id, + viewport: inner, + offset: 0, + size: Size::new(inner.width, inner.height), + }); + app.media_previews + .get(&(chat_id, message_id)) + .map_or("Loading preview…", |preview| preview.status.as_str()) + } else { + "This media is no longer available" + }; + if !status.is_empty() { + frame.render_widget( + Paragraph::new(status).style(Style::default().fg(MUTED)), + inner, + ); + } + let hint = |action| app.keymap.hint(Context::Preview, action); + let close = format!("{} close ", hint("cancel")); + let footer = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(clamp_u16(close.width())), + ]) + .split(rows[1]); + let controls = app + .status_message + .clone() + .unwrap_or_else(|| format!(" {} reply · {} reveal file", hint("reply"), hint("reveal"))); + frame.render_widget( + Paragraph::new(controls).style(Style::default().fg(if app.status_message.is_some() { + WARNING + } else { + MUTED + })), + footer[0], + ); + frame.render_widget( + Paragraph::new(close).style(Style::default().fg(MUTED)), + footer[1], + ); +} diff --git a/src/ui/search.rs b/src/ui/search.rs new file mode 100644 index 0000000..a8c1f5a --- /dev/null +++ b/src/ui/search.rs @@ -0,0 +1,145 @@ +use super::{ + AppState, Clear, Constraint, DANGER, Frame, Layout, List, ListItem, ListState, Local, MUTED, + Modifier, Paragraph, Position, Rect, Style, Wrap, centered, clamp_u16, pane_block, spinner, + truncate_cells, +}; +use crate::keymap::Context; + +pub(super) fn render_search(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let popup = centered( + area, + area.width.min(110), + area.height.saturating_sub(2).max(8), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" Local regex · {} ", app.search.scope_label), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(inner); + let input = &app.search.query; + let scroll = input + .cursor_display_width() + .saturating_sub(usize::from(rows[0].width.saturating_sub(3))); + frame.render_widget( + Paragraph::new(input.value()) + .scroll((0, clamp_u16(scroll))) + .block(pane_block(" Pattern ".to_owned(), app.search.editing)), + rows[0], + ); + if app.search.editing { + frame.set_cursor_position(Position::new( + rows[0].x + 1 + clamp_u16(input.cursor_display_width().saturating_sub(scroll)), + rows[0].y + 1, + )); + } + let coverage = coverage_label(app); + frame.render_widget( + Paragraph::new(coverage) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(if app.search.error.is_some() { + DANGER + } else { + MUTED + })), + rows[1], + ); + let height = usize::from(rows[2].height).max(1); + let start = app.search.selected.saturating_add(1).saturating_sub(height); + let messages = app + .search + .page + .as_ref() + .map_or(&[][..], |page| page.messages.as_slice()); + let items: Vec<_> = messages + .iter() + .skip(start) + .take(height) + .map(|message| result_row(message, app, usize::from(rows[2].width.saturating_sub(2)))) + .collect(); + let items = if items.is_empty() && app.search.page.is_some() && !app.search.loading { + vec![ListItem::new("No matches in the cached range")] + } else { + items + }; + let mut state = ListState::default() + .with_selected((!messages.is_empty()).then_some(app.search.selected.saturating_sub(start))); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[2], + &mut state, + ); + let hint = |action| app.keymap.hint(Context::Search, action); + frame.render_widget( + Paragraph::new(format!( + "{} {} · {} close\n{} scope · {} edit\n{} previous · {} next", + hint("open"), + if app.search.editing { "search" } else { "open" }, + hint("cancel"), + hint("search_scope"), + hint("search_query"), + hint("search_previous"), + hint("search_more") + )) + .style(Style::default().fg(MUTED)), + rows[3], + ); +} + +fn coverage_label(app: &AppState) -> String { + if let Some(error) = &app.search.error { + error.clone() + } else if app.search.loading { + format!("{} Searching cached messages…", spinner(app.tick)) + } else if let Some(page) = &app.search.page { + let date = |time: Option| { + time.and_then(|time| chrono::DateTime::from_timestamp(time, 0)) + .map_or_else( + || "—".to_owned(), + |date| date.format("%Y-%m-%d").to_string(), + ) + }; + format!( + "{} cached messages · {} to {} UTC · page {} · {} hits{}", + page.cached_messages, + date(page.oldest), + date(page.newest), + app.search.page_number, + page.messages.len(), + if page.next.is_some() { + " · more available" + } else { + "" + } + ) + } else { + "Searches cached message text only; older uncached history is not included.".to_owned() + } +} + +fn result_row(message: &crate::model::Message, app: &AppState, width: usize) -> ListItem<'static> { + let title = app + .chats + .iter() + .find(|chat| chat.id == message.chat_id) + .map_or("Unknown", |chat| chat.title.as_str()); + ListItem::new(truncate_cells( + &format!( + "{} · {} · {}", + title, + message + .timestamp + .with_timezone(&Local) + .format("%m-%d %H:%M"), + crate::model::sanitize_terminal_line(&message.text) + ), + width, + )) +} diff --git a/tests/session_concurrency.rs b/tests/session_concurrency.rs index 3e6b4e6..52e68a2 100644 --- a/tests/session_concurrency.rs +++ b/tests/session_concurrency.rs @@ -8,27 +8,38 @@ fn concurrent_session_processes() { let directory = std::env::temp_dir().join(format!("termgram-session-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); let path = directory.join("shared.session"); - let mut children = (0..4) + let children = (0..4) .map(|_| { Command::new(std::env::current_exe().unwrap()) .args(["--exact", "session_writer", "--nocapture"]) .env("TERMGRAM_TEST_SESSION", &path) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .unwrap() }) .collect::>(); - let success = children - .iter_mut() - .all(|child| child.wait().unwrap().success()); - // Reap every child even if an earlier writer failed. - for child in &mut children { - let _ = child.wait(); - } + // Reap every child and retain its diagnostics, including native exit codes. + let failures = children + .into_iter() + .enumerate() + .filter_map(|(index, child)| { + let output = child.wait_with_output().unwrap(); + (!output.status.success()).then(|| { + format!( + "writer {index}: {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ) + }) + }) + .collect::>(); std::fs::remove_dir_all(directory).unwrap(); assert!( - success, - "all processes must initialize and update the shared session" + failures.is_empty(), + "all processes must initialize and update the shared session:\n{}", + failures.join("\n") ); } diff --git a/tests/update_recovery.rs b/tests/update_recovery.rs new file mode 100644 index 0000000..59ff04b --- /dev/null +++ b/tests/update_recovery.rs @@ -0,0 +1,109 @@ +use grammers_client::tl; +use grammers_session::{ + types::{ChannelState, UpdatesState}, + updates::{MessageBoxes, UpdatesLike}, +}; + +#[test] +fn startup_recovers_common_state_without_scanning_every_channel() { + let state = UpdatesState { + pts: 12, + qts: 0, + date: 123, + seq: 1, + channels: (1..=500).map(|id| ChannelState { id, pts: 20 }).collect(), + }; + let boxes = MessageBoxes::load(state); + assert!(boxes.get_difference().is_some()); + assert!(boxes.get_channel_difference().is_none()); + assert_eq!(boxes.session_state().channels.len(), 500); +} + +#[test] +fn channel_gap_recovers_from_the_local_cursor() { + let mut boxes = MessageBoxes::new(); + boxes.try_set_channel_state(42, 20); + boxes + .process_updates(UpdatesLike::Updates(tl::enums::Updates::UpdateShort( + tl::types::UpdateShort { + update: tl::types::UpdateChannelTooLong { + channel_id: 42, + pts: Some(30), + } + .into(), + date: 123, + }, + ))) + .unwrap(); + let request = boxes + .get_channel_difference() + .expect("channel gap must trigger recovery"); + assert_eq!(request.pts, 20); +} + +#[tokio::test] +async fn sdk_delivers_the_whole_batch_before_its_checkpoint() { + use grammers_client::{Client, SenderPool, client::UpdatesConfiguration}; + use grammers_session::storages::MemorySession; + use std::sync::Arc; + let pool = SenderPool::new(Arc::new(MemorySession::default()), 1); + let client = Client::new(pool.handle); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let mut stream = client + .stream_updates( + receiver, + UpdatesConfiguration { + catch_up: false, + update_queue_limit: None, + }, + ) + .await + .unwrap(); + sender + .send(UpdatesLike::Updates( + tl::types::Updates { + updates: vec![tl::enums::Update::PtsChanged, tl::enums::Update::PtsChanged], + users: Vec::new(), + chats: Vec::new(), + date: 123, + seq: 1, + } + .into(), + )) + .unwrap(); + let (updates, cursor) = Box::pin(stream.next_batch()).await.unwrap(); + assert_eq!(updates.len(), 2); + assert_eq!(cursor.seq, 1); + assert_eq!(cursor.date, 123); +} + +#[test] +fn active_group_uses_server_timeout_and_stops_when_closed() { + let mut boxes = MessageBoxes::new(); + boxes.try_set_channel_state(42, 20); + boxes.set_active_channel(Some((42, 99))); + assert_eq!(boxes.get_channel_difference().unwrap().pts, 20); + boxes.apply_channel_difference( + tl::types::updates::ChannelDifferenceEmpty { + r#final: true, + pts: 21, + timeout: Some(3), + } + .into(), + ); + assert!(boxes.get_channel_difference().is_none()); + let delay = boxes + .check_deadlines() + .duration_since(std::time::Instant::now()); + assert!(delay.as_secs() <= 3); + assert!(delay.as_secs() >= 2); + boxes.set_active_channel(None); + let delay = boxes + .check_deadlines() + .duration_since(std::time::Instant::now()); + assert!( + delay.as_secs() > 60, + "closing the group stops short polling" + ); + assert!(boxes.get_channel_difference().is_none()); +} diff --git a/vendor/README.md b/vendor/README.md index ed55552..19afc92 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -45,3 +45,59 @@ WAL is not necessary for multi-process operation, and the bundled libSQL SQLite engine predates upstream's WAL-reset fix. Reapply and review these changes when upgrading the session crate. `tests/session_concurrency.rs` validates independent processes opening and writing one session, including initial schema creation. + +`src/message_box/mod.rs` also follows Telegram's documented startup recovery: +only common/secondary differences start immediately; channel differences are +requested when Telegram reports a gap, including `updateChannelTooLong`. +That notification preserves an existing local channel PTS instead of replacing +it with the remote PTS before recovery. The upstream connection-flow assertion +is adjusted; `tests/update_recovery.rs` checks both integration boundaries. +See https://core.telegram.org/api/updates#recovering-gaps. + +`grammers-client/` vendors crates.io 0.10.0 from the same Grammers revision +(`grammers-client/`). Original source, examples, tests and both licenses are +retained. Only `src/client/updates.rs` is adapted: + +- `next_batch` drains a complete update batch and returns its covered cursor; +- `restore_state` loads the application's durable cursor before polling; +- too-long differences emit a raw `PtsChanged` / `ChannelTooLong` invalidation + before replacement messages, allowing the local store to discard stale data. + +`src/telegram/local.rs` persists events before committing their cursor and reads +that cursor on restart. Telegram's session peer cache remains separate: dialog +iteration may advance its PTS without having cached any message content. + +Active-group polling uses the same message-box engine. `set_active_channel` +seeds only unknown PTS, recovers the viewed group immediately, and schedules its +next final difference using the server timeout (one second when absent). Leaving +the group restores the normal recovery deadline. A watch channel wakes the +existing update receive without cancellation or a second update consumer. +The deadline helper also accounts for deadlines moved earlier than the current +minimum. `tests/update_recovery.rs` covers activation, timeout and deactivation. + +`libsql/` vendors the published libSQL 0.9.30 Rust crate from +https://github.com/tursodatabase/libsql at +`0653c5788d77ef16a97c56ff3e9fdc11717a72d9` (`libsql/`), with the upstream +repository's `LICENSE.md`. The published standalone manifest, lockfile and original +source, examples and tests are retained; Cargo download metadata is omitted. An +empty workspace table allows the upstream tests to run independently of Termgram. +The root lockfile resolves the application dependency. + +The only source changes backport upstream PR #2282 at +`0070ff3331cd6d09425b812e1cd3ebe32e1d4206`, including its regression test: +https://github.com/tursodatabase/libsql/pull/2282. +`src/local/connection.rs` makes disconnect idempotent, and `src/local/impls.rs` +removes the redundant connection destructor. This prevents a second +`sqlite3_close_v2` call on freed memory, reported in upstream issue #2251 and +consistent with Termgram's Windows ARM64 session-writer access violations. +The native SQLite engine and session format are unchanged. Directly using that +upstream Git revision would also require its 0.10.0-pre.4 engine upgrade; this +backport keeps the fix bounded to connection ownership. Remove this patch when +a stable release includes the fix. Keep upstream formatting and use its focused +`drop_closes_the_handle_exactly_once` test to validate the ownership boundary. + +```sh +cargo test --locked --manifest-path vendor/libsql/Cargo.toml \ + --no-default-features --features core --lib \ + drop_closes_the_handle_exactly_once --target-dir target/libsql-regression +``` diff --git a/vendor/grammers-client/.cargo_vcs_info.json b/vendor/grammers-client/.cargo_vcs_info.json new file mode 100644 index 0000000..87015b7 --- /dev/null +++ b/vendor/grammers-client/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "5c6d44ff30e02d6c9295bcf1fcb51403ad77c981" + }, + "path_in_vcs": "grammers-client" +} \ No newline at end of file diff --git a/vendor/grammers-client/Cargo.lock b/vendor/grammers-client/Cargo.lock new file mode 100644 index 0000000..ec27e6a --- /dev/null +++ b/vendor/grammers-client/Cargo.lock @@ -0,0 +1,2348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[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-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "glass_pumpkin" +version = "2.0.0-rc0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec260059e5d59a4899d78dff8bf59abf81f8f5b3b44e9d3ba25c9dade1390a36" +dependencies = [ + "getrandom 0.4.3", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rand_core", +] + +[[package]] +name = "grammers-client" +version = "0.10.0" +dependencies = [ + "chrono", + "futures-util", + "grammers-crypto", + "grammers-mtsender", + "grammers-session", + "grammers-tl-types", + "html5ever", + "log", + "md5", + "mime_guess", + "pin-project-lite", + "pulldown-cmark", + "simple_logger", + "tokio", + "toml", + "url", +] + +[[package]] +name = "grammers-crypto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fe0fbd93bce6965d08248abdfec2d2544e11cb8d5c91dba3369c0a4295fd85" +dependencies = [ + "aes", + "ctr", + "getrandom 0.4.3", + "glass_pumpkin", + "hmac", + "num-bigint", + "num-traits", + "pbkdf2", + "sha1", + "sha2", +] + +[[package]] +name = "grammers-mtproto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8d68ffa402c5f0707e22c29d44229eb01e968017dd0fb27e1adab953d49fc3" +dependencies = [ + "bytes", + "crc32fast", + "flate2", + "getrandom 0.4.3", + "grammers-crypto", + "grammers-tl-types", + "log", + "num-bigint", + "sha1", +] + +[[package]] +name = "grammers-mtsender" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f39b8556ddc94f7ec935135dcfbf84ca8628538e103779cdc701423b759b078e" +dependencies = [ + "bytes", + "grammers-crypto", + "grammers-mtproto", + "grammers-session", + "grammers-tl-types", + "hickory-resolver", + "locate-locale", + "log", + "os_info", + "tokio", + "tokio-socks", + "url", +] + +[[package]] +name = "grammers-session" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4d5b9e434c6b9fc1e091fe82d1f201d8e19df5005d2c19e218b4977b4df506" +dependencies = [ + "grammers-tl-types", + "log", + "tokio", +] + +[[package]] +name = "grammers-tl-gen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a37dda68c9e775dfbffb85b99826b5ac8144e9da40be3327e77cbcd8618fc30" +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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d450e2f2588af535fe85a17b9a6df8338794ee8ad6bcb1b97d61a14d472fc5" +dependencies = [ + "grammers-tl-gen", + "grammers-tl-parser", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "new_debug_unreachable" +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", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "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", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "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", + "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", + "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", + "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 = "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 = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "precomputed-hash" +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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "windows-sys 0.61.2", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "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 = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/vendor/grammers-client/Cargo.toml b/vendor/grammers-client/Cargo.toml new file mode 100644 index 0000000..33d72c2 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml @@ -0,0 +1,145 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +readme = "README.md" +keywords = [ + "mtproto", + "telegram", + "tl", +] +categories = ["api-bindings"] +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/Lonami/grammers" +resolver = "2" + +[features] +default = ["fs"] +fs = ["tokio/fs"] +html = ["html5ever"] +markdown = ["pulldown-cmark"] +parse_invite_link = ["url"] +proxy = ["grammers-mtsender/proxy"] + +[lib] +name = "grammers_client" +path = "src/lib.rs" + +[[example]] +name = "dialogs" +path = "examples/dialogs.rs" + +[[example]] +name = "downloader" +path = "examples/downloader.rs" + +[[example]] +name = "echo" +path = "examples/echo.rs" + +[[example]] +name = "inline-pagination" +path = "examples/inline-pagination.rs" + +[[example]] +name = "ping" +path = "examples/ping.rs" + +[[example]] +name = "send_album" +path = "examples/send_album.rs" + +[[test]] +name = "deps" +path = "tests/deps.rs" + +[dependencies.chrono] +version = "0.4.45" + +[dependencies.futures-util] +version = "0.3.32" +features = ["alloc"] +default-features = false + +[dependencies.grammers-crypto] +version = "0.10.0" + +[dependencies.grammers-mtsender] +version = "0.10.0" + +[dependencies.grammers-session] +version = "0.10.0" +default-features = false + +[dependencies.grammers-tl-types] +version = "0.10.0" +features = ["tl-mtproto"] + +[dependencies.html5ever] +version = "0.39.0" +optional = true + +[dependencies.log] +version = "0.4.33" + +[dependencies.md5] +version = "0.8.0" + +[dependencies.mime_guess] +version = "2.0.5" + +[dependencies.pin-project-lite] +version = "0.2" + +[dependencies.pulldown-cmark] +version = "0.13.4" +optional = true +default-features = false + +[dependencies.tokio] +version = "1.52.3" +features = [ + "rt", + "macros", +] +default-features = false + +[dependencies.url] +version = "2.5.8" +optional = true + +[dev-dependencies.simple_logger] +version = "5.2.0" +features = ["colors"] +default-features = false + +[dev-dependencies.tokio] +version = "1.52.3" +features = ["signal"] +default-features = false + +[dev-dependencies.toml] +version = "1.1.2" diff --git a/vendor/grammers-client/Cargo.toml.orig b/vendor/grammers-client/Cargo.toml.orig new file mode 100755 index 0000000..9c43261 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml.orig @@ -0,0 +1,53 @@ +[package] +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +license = "MIT OR Apache-2.0" +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +repository = "https://codeberg.org/Lonami/grammers" +keywords = ["mtproto", "telegram", "tl"] +categories = ["api-bindings"] +edition = "2024" + +[features] +markdown = ["pulldown-cmark"] +html = ["html5ever"] +proxy = ["grammers-mtsender/proxy"] +parse_invite_link = ["url"] +fs = ["tokio/fs"] +default = ["fs"] + +[dependencies] +chrono = "0.4.45" +futures-util = { version = "0.3.32", default-features = false, features = [ + "alloc" +] } +grammers-crypto = { path = "../grammers-crypto", version = "0.10.0" } +grammers-mtsender = { path = "../grammers-mtsender", version = "0.10.0" } +grammers-session = { path = "../grammers-session", version = "0.10.0", default-features = false } +grammers-tl-types = { path = "../grammers-tl-types", version = "0.10.0", features = [ + "tl-mtproto", +] } +html5ever = { version = "0.39.0", optional = true } +log = "0.4.33" +md5 = "0.8.0" +mime_guess = "2.0.5" +pin-project-lite = "0.2" +pulldown-cmark = { version = "0.13.4", default-features = false, optional = true } +tokio = { version = "1.52.3", default-features = false, features = [ + "rt", "macros" +] } +url = { version = "2.5.8", optional = true } + +[dev-dependencies] +tokio = { version = "1.52.3", default-features = false, features = [ + "signal", +] } +simple_logger = { version = "5.2.0", default-features = false, features = [ + "colors", +] } +toml = "1.1.2" diff --git a/vendor/grammers-client/DEPS.md b/vendor/grammers-client/DEPS.md new file mode 100755 index 0000000..18d9d0e --- /dev/null +++ b/vendor/grammers-client/DEPS.md @@ -0,0 +1,70 @@ +# Dependencies + +## grammers-crypto + +Used for supporting logging in to accounts with 2-factor authentication enabled. + +## grammers-mtsender + +Used to drive the network connection to Telegram. + +## grammers-session + +Used to load and store session data, such as authorization key or current user identifier. + +It also contains the logic needed to correctly process updates. + +## grammers-tl-types + +Used everywhere to invoke the "raw Telegram's API". It is the implementation of all the friendly +client methods. + +## pulldown-cmark + +Enables the user to use markdown text to send formatted messages. + +## html5ever + +Enables the user to use HTML text to send formatted messages. + +## tokio + +Used to coordinate the asynchronous methods of the client. + +## log + +Used to log the execution of the client to help debug issues. + +## md5 + +Needed when uploading files to Telegram. + +## mime_guess + +Used to guess the mime-type of uploaded files when sending media unless the user explicitly sets +the mime-type themselves. The mime-type is required by Telegram. + +## chrono + +Used for defining date types (for example, accessing the date of when a message was sent). + +## simple_logger + +Used by the examples to showcase how one may configure logging for more information. + +## toml + +Used to test that this file lists all dependencies from `Cargo.toml`. + +## pin-project-lite + +Used for return custom types that `impl Future` so that the requests can be further configured +without having to use `Box`. + +## futures-util + +Provides useful functions for working with futures/tasks. + +## url + +Used to parse certain URLs to offer features such as joining private chats via their invite link. diff --git a/vendor/grammers-client/LICENSE-APACHE b/vendor/grammers-client/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/vendor/grammers-client/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/grammers-client/LICENSE-MIT b/vendor/grammers-client/LICENSE-MIT new file mode 100644 index 0000000..5f4dc02 --- /dev/null +++ b/vendor/grammers-client/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Lonami + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/grammers-client/README.md b/vendor/grammers-client/README.md new file mode 100755 index 0000000..fcb4f9a --- /dev/null +++ b/vendor/grammers-client/README.md @@ -0,0 +1,28 @@ +# grammers-client + +This library is a higher-level interface to interact with Telegram's API. + +It contains the code necessary to create a client, connect to the API and +make Remote Procedure Calls (RPC) to it, such as signing in or sending a +message. + +The library is in development, but new releases are only cut rarely. +[Specifying the dependency from the git repository][dep-git] is recommended: + +```toml +grammers-client = { git = "https://codeberg.org/Lonami/grammers.git" } +``` + +Please note that traits across versions are not always compatible. +If you depend on other `grammers-` crates, be sure all of them use +a compatible version (e.g. all of them using `git`). + +Note that `grammers-tl-types` (needed to `client.invoke` "raw" functions) +is currently re-exported from within this crates, so it's easier to use the +re-export than to depend on the crate separatedly: + +```rust +use grammers_client::grammers_tl_types as tl; +``` + +[dep-git]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-dependencies-from-git-repositories diff --git a/vendor/grammers-client/examples/README.md b/vendor/grammers-client/examples/README.md new file mode 100755 index 0000000..6f8af3e --- /dev/null +++ b/vendor/grammers-client/examples/README.md @@ -0,0 +1,42 @@ +# grammers-client examples + +This folder contains various simple, self-contained examples that you can use to base your +projects on. If you would like to see any other example that could be useful for others, or +you think these could be improved, feel free to contribute. + +For the time being, the examples are licensed in the same way as the libraries. + +## [ping.rs] + +Pings Telegram by using raw API. This is the simplest possible example, and requires no valid +API ID or hash, since it's not logging in to any account. + +## [echo.rs] + +Simple echo-bot. It will respond to plain text messages with the same content. The updates will +be processed concurrently to showcase how one might use multiple handles the same time. In a +real-world scenario, you would probably want to use some form of task pool to spawn tasks within +limits. Or you might not need this at all if you want to process updates in order. + +## [dialogs.rs] + +Logs in to a user account and prints the title and ID of all the dialogs (chats they have joined +to and private conversations). It shows how to spawn a task to handle the network in the +background and using a single client handle to actually perform remote calls. If the client was +not running and thus not handling the network, no response would ever arrive to the handles! + +This separation between client, which deals with all network events, and client handles, enables +you to have as many handles as you need, for as many tasks as you need (and enabling things like +iterators which keep their own handle). It requires some boilerplate to setup, but it's a very +powerful approach. + +## [downloader.rs] + +Downloads all media from a peer, including files located in datacenters different than your own. +After logging in, the example iterates all messages of the supplied peer name, printing in the console +the ID and the text for each one; videos, images, stickers and contacts are saved in the `target` folder. + +[ping.rs]: ping.rs +[echo.rs]: echo.rs +[dialogs.rs]: dialogs.rs +[downloader.rs]: downloader.rs diff --git a/vendor/grammers-client/examples/dialogs.rs b/vendor/grammers-client/examples/dialogs.rs new file mode 100755 index 0000000..5c52ca9 --- /dev/null +++ b/vendor/grammers-client/examples/dialogs.rs @@ -0,0 +1,97 @@ +//! Example to print the ID and title of all the dialogs. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example dialogs +//! ``` + +use std::env; +use std::io::{self, BufRead as _, Write as _}; +use std::sync::Arc; + +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "dialogs.session"; + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or("None"); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let mut dialogs = client.iter_dialogs(); + + println!("Showing up to {} dialogs:", dialogs.total().await?); + while let Some(dialog) = dialogs.next().await? { + let peer = dialog.peer(); + println!("- {: >10} {}", peer.id(), peer.name().unwrap_or_default()); + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/downloader.rs b/vendor/grammers-client/examples/downloader.rs new file mode 100644 index 0000000..78536f4 --- /dev/null +++ b/vendor/grammers-client/examples/downloader.rs @@ -0,0 +1,153 @@ +//! Example to download all messages and media from a peer. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example downloader -- PEER_NAME +//! ``` +//! +//! Messages will be printed to stdout, and media will be saved in the `target/` folder locally, named +//! message-[MSG_ID].[EXT] + +use std::io::{BufRead, Write}; +use std::path::Path; +use std::sync::Arc; +use std::{env, io}; + +use grammers_client::media::Media; +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use mime::Mime; +use mime_guess::mime; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "downloader.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let peer_name = env::args().nth(1).expect("peer name missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap(); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let maybe_peer = client + .resolve_username(peer_name.as_str()) + .await? + .ok_or("no peer with username")? + .to_ref() + .await?; + + let peer = maybe_peer.unwrap_or_else(|| panic!("Peer {peer_name} could not be found")); + + let mut messages = client.iter_messages(peer); + + println!( + "Peer {} has {} total messages.", + peer_name, + messages.total().await.unwrap() + ); + + let mut counter = 0; + + while let Some(msg) = messages.next().await? { + counter += 1; + println!("Message {}:{}", msg.id(), msg.text()); + if let Some(media) = msg.media() { + let dest = format!( + "target/message-{}{}", + &msg.id().to_string(), + get_file_extension(&media) + ); + client + .download_media(&media, &Path::new(dest.as_str())) + .await + .expect("Error downloading message"); + } + } + + println!("Downloaded {counter} messages"); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn get_file_extension(media: &Media) -> String { + match media { + Media::Photo(_) => ".jpg".to_string(), + Media::Sticker(sticker) => get_mime_extension(sticker.document.mime_type()), + Media::Document(document) => get_mime_extension(document.mime_type()), + Media::Contact(_) => ".vcf".to_string(), + _ => String::new(), + } +} + +fn get_mime_extension(mime_type: Option<&str>) -> String { + mime_type + .map(|m| { + let mime: Mime = m.parse().unwrap(); + format!(".{}", mime.subtype()) + }) + .unwrap_or_default() +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/examples/echo.rs b/vendor/grammers-client/examples/echo.rs new file mode 100644 index 0000000..29d0899 --- /dev/null +++ b/vendor/grammers-client/examples/echo.rs @@ -0,0 +1,139 @@ +//! Example to echo user text messages. Updates are handled concurrently. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` + +use std::sync::Arc; +use std::{env, time::Duration}; + +use grammers_client::Client; +use grammers_client::client::UpdatesConfiguration; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::task::JoinSet; +use tokio::{runtime, time::sleep}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "echo.session"; + +async fn handle_update(client: Client, update: Update) { + match update { + Update::NewMessage(message) if !message.outgoing() => { + let peer = message.peer().unwrap(); + println!( + "Responding to {}", + peer.name().unwrap_or(&format!("id {}", message.peer_id())) + ); + if message.text() == "slow" { + sleep(Duration::from_secs(5)).await; + } + if let Err(e) = client + .send_message(peer.to_ref().await.unwrap().unwrap(), message.text()) + .await + { + println!("Failed to respond! {e}"); + }; + } + _ => {} + } +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + updates, + handle, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle.clone()); + let pool_task = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + + // This example spawns a task to handle each update. + // To guarantee that all handlers run to completion, they're stored in this set. + // You can use `task::spawn` if you don't care about dropping unfinished handlers midway. + let mut handler_tasks = JoinSet::new(); + let mut updates = client + .stream_updates( + updates, + UpdatesConfiguration { + catch_up: true, + ..Default::default() + }, + ) + .await?; + loop { + // Empty finished handlers (you could look at their return value here too.) + while let Some(_) = handler_tasks.try_join_next() {} + + // This code uses `select` on Ctrl+C to gracefully stop the client and have a chance to + // save the session. You could have fancier logic to save the session if you wanted to + // (or even save it on every update). Or you could also ignore Ctrl+C and just use + // `let update = client.next_update().await?`. + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + handler_tasks.spawn(handle_update(handle, update)); + } + } + } + + println!("Saving session file..."); + updates.sync_update_state().await?; // you usually want this before closing the session + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // Here there are at least three handles alive: `handle`, `client` and `updates` + // which contains a `client`. Any ongoing `handle_update` handlers have one client too. + // In this case, it's easier to call `handle.quit()` to close them all. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + // This also gives a chance to the handlers to finish their work by handling the `Dropped` + // error from any pending method calls (RPC invocations). + // + // You can try this graceful shutdown by sending a message saying "slow" and then pressing Ctrl+C. + println!("Gracefully closing connection to notify all pending handlers..."); + handle.quit(); + let _ = pool_task.await; + + // Give a chance to all on-going handlers to finish. + println!("Waiting for any slow handlers to finish..."); + while let Some(_) = handler_tasks.join_next().await {} + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/inline-pagination.rs b/vendor/grammers-client/examples/inline-pagination.rs new file mode 100644 index 0000000..d8bd827 --- /dev/null +++ b/vendor/grammers-client/examples/inline-pagination.rs @@ -0,0 +1,168 @@ +//! Example to showcase how to achieve pagination with inline buttons. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` +//! +//! In order to achieve pagination, the button data must contain enough information for the code +//! to determine which "offset" should it use when querying additional data. +//! +//! For this example, the button contains the last two values of the fibonacci sequence where it +//! last left off, separated by a comma. This way, the code can resume. +//! +//! If there is no comma, then there is a single number, which we use to know what was clicked. +//! +//! If it's the special string "done", then we know we've reached the end (there is a limit to +//! how much data a button's payload can contain, and to keep it simple, we're storing it inline +//! in decimal, so the numbers can't get too large). + +use std::env; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::message::{Button, InputMessage, ReplyMarkup}; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::{runtime, task}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "inline-pagination.session"; + +const NUMBERS_PER_PAGE: usize = 4; + +// https://core.telegram.org/bots/api#inlinekeyboardbutton +const MAX_PAYLOAD_DATA_LEN: usize = 64; + +/// Generate the inline keyboard reply markup with a few more numbers from the sequence. +fn fib_markup(mut a: u128, mut b: u128) -> ReplyMarkup { + let mut rows = Vec::with_capacity(NUMBERS_PER_PAGE + 1); + for _ in 0..NUMBERS_PER_PAGE { + let text = a.to_string(); + rows.push(vec![Button::data(&text, text.as_bytes())]); + + let bb = b; + b += a; + a = bb; + } + + let next = format!("{a},{b}"); + if next.len() > MAX_PAYLOAD_DATA_LEN { + rows.push(vec![Button::data("I'm satisfied!!", b"done".to_vec())]); + } else { + rows.push(vec![ + Button::data("Restart!", b"0,1".to_vec()), + Button::data("More!", format!("{a},{b}").into_bytes()), + ]); + } + ReplyMarkup::from_buttons(&rows) +} + +async fn handle_update(_client: Client, update: Update) -> Result { + match update { + Update::NewMessage(message) if message.text() == "/start" => { + message + .respond( + InputMessage::new() + .text("Here's a fibonacci") + .reply_markup(fib_markup(0, 1)), + ) + .await?; + } + Update::CallbackQuery(query) => { + let data = std::str::from_utf8(query.data()).unwrap(); + println!("Got callback query for {data}"); + + // First check special-case. + if data == "done" { + query.answer().edit("Glad you liked it 👍").await?; + return Ok(()); + } + + // Otherwise get the stored number(s). + let mut parts = data.split(','); + let a = parts.next().unwrap().parse::().unwrap(); + if let Some(b) = parts.next() { + let os = (0..b.len()).map(|_| 'o').collect::(); + let b = b.parse::().unwrap(); + query + .answer() + .edit( + InputMessage::from(format!("S{os} much fibonacci 🔢")) + .reply_markup(fib_markup(a, b)), + ) + .await?; + } else if a % 2 == 0 { + query.answer().text("Even that's a number!").send().await?; + } else { + query.answer().alert("That's odd…").send().await?; + } + } + _ => {} + } + + Ok(()) +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + handle, + updates, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + let mut updates = client.stream_updates(updates, Default::default()).await?; + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + task::spawn(async move { + match handle_update(handle, update).await { + Ok(_) => {} + Err(e) => eprintln!("Error handling updates!: {e}"), + } + }); + } + } + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/ping.rs b/vendor/grammers-client/examples/ping.rs new file mode 100755 index 0000000..4bc026a --- /dev/null +++ b/vendor/grammers-client/examples/ping.rs @@ -0,0 +1,42 @@ +//! This example sends a ping to Telegram through raw API, and that's it. +//! +//! ```sh +//! cargo run --example ping +//! ``` + +use std::sync::Arc; + +use grammers_client::Client; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_tl_types as tl; +use tokio::runtime; + +type Result = std::result::Result<(), Box>; + +async fn async_main() -> Result { + let session = Arc::new(SqliteSession::open("ping.session").await?); + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), 1); + let client = Client::new(handle); + let pool_task = tokio::spawn(runner.run()); + + println!("Sending ping..."); + dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + println!("Ping sent successfully!"); + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + drop(client); + let _ = pool_task.await; + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/send_album.rs b/vendor/grammers-client/examples/send_album.rs new file mode 100644 index 0000000..884e347 --- /dev/null +++ b/vendor/grammers-client/examples/send_album.rs @@ -0,0 +1,125 @@ +use std::env; +use std::io; +use std::io::{BufRead, Write}; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::SignInError; +use grammers_client::media::InputMedia; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_session::types::{PeerAuth, PeerId, PeerRef}; +use grammers_tl_types as tl; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "me.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id: i32 = env!("TG_ID").parse().expect("TG_ID invalid"); + let api_hash: String = env!("TG_HASH").to_string(); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, &api_hash).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or(""); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let peer = client + .resolve_username("telegram") + .await? + .ok_or("no peer with username")? + .to_ref() + .await? + .unwrap(); + + let messages = client.get_messages_by_id(peer, &[437, 438]).await?; + let medias = messages + .into_iter() + .map(|message| { + let media = message + .unwrap() + .media() + .unwrap() + .to_raw_input_media() + .unwrap(); + let (id, video_cover, video_timestamp) = match media { + tl::enums::InputMedia::Document(x) => (x.id, x.video_cover, x.video_timestamp), + _ => panic!("must be document"), + }; + let media = tl::types::InputMediaDocument { + spoiler: true, + id, + video_cover, + video_timestamp, + ttl_seconds: None, + query: None, + }; + InputMedia::new().media(media) + }) + .collect(); + + let peer = PeerRef { + id: PeerId::self_user(), + auth: PeerAuth::default(), + }; + + let m = client.send_album(peer, medias).await?; + println!("{:?}", m); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/src/client/auth.rs b/vendor/grammers-client/src/client/auth.rs new file mode 100644 index 0000000..d4d10ee --- /dev/null +++ b/vendor/grammers-client/src/client/auth.rs @@ -0,0 +1,497 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; + +use grammers_crypto::two_factor_auth::{calculate_2fa, check_p_and_g}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerInfo, UpdateState, UpdatesState}; +use grammers_tl_types as tl; + +use super::Client; +use crate::peer::User; +use crate::utils; + +/// The error type which is returned when signing in fails. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum SignInError { + /// Sign-up with an official client is required. + /// + /// Third-party applications, such as those developed with *grammers*, + /// cannot be used to register new accounts. For more details, see this + /// [comment regarding third-party app sign-ups](https://bugs.telegram.org/c/25410/1): + /// > \[…] if a user doesn’t have a Telegram account yet, + /// > they will need to create one first using an official mobile Telegram app. + SignUpRequired, + /// The account has 2FA enabled, and the password is required. + PasswordRequired(PasswordToken), + /// The code used to complete login was not valid. + InvalidCode, + /// The 2FA password used to complete login was not valid. + InvalidPassword(PasswordToken), + /// A generic invocation error occured. + Other(InvocationError), +} + +impl fmt::Display for SignInError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use SignInError::*; + match self { + SignUpRequired => write!(f, "sign in error: sign up with official client required"), + PasswordRequired(_password) => write!(f, "2fa password required"), + InvalidCode => write!(f, "sign in error: invalid code"), + InvalidPassword(_password) => write!(f, "invalid password"), + Other(e) => write!(f, "sign in error: {e}"), + } + } +} + +impl std::error::Error for SignInError {} + +/// Login token needed to continue the login process after sending the code. +pub struct LoginToken { + pub(crate) phone: String, + pub(crate) phone_code_hash: String, +} + +/// Password token needed to complete a 2FA login. +#[derive(Debug)] +pub struct PasswordToken { + pub(crate) password: tl::types::account::Password, +} + +impl PasswordToken { + pub fn new(password: tl::types::account::Password) -> Self { + PasswordToken { password } + } + + pub fn hint(&self) -> Option<&str> { + self.password.hint.as_deref() + } +} + +/// Method implementations related with the authentication of the user into the API. +/// +/// Most requests to the API require the user to have authorized their key, stored in the session, +/// before being able to use them. +impl Client { + /// Returns `true` if the current account is authorized. Otherwise, + /// logging in will be required before being able to invoke requests. + /// + /// This will likely be the first method you want to call on a connected [`Client`]. After you + /// determine if the account is authorized or not, you will likely want to use either + /// [`Client::bot_sign_in`] or [`Client::request_login_code`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.is_authorized().await? { + /// println!("Client already authorized and ready to use!"); + /// } else { + /// println!("Client is not authorized, you will need to sign_in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn is_authorized(&self) -> Result { + match self.invoke(&tl::functions::updates::GetState {}).await { + Ok(_) => Ok(true), + Err(InvocationError::Rpc(e)) if e.code == 401 => Ok(false), + Err(err) => Err(err), + } + } + + async fn complete_login( + &self, + auth: tl::types::auth::Authorization, + ) -> Result { + // In the extremely rare case where `Err` happens, there's not much we can do. + // `message_box` will try to correct its state as updates arrive. + let update_state = self.invoke(&tl::functions::updates::GetState {}).await.ok(); + + let user = User::from_raw(self, auth.user); + let auth = user.to_ref().await?.unwrap().auth; + + self.0 + .session + .cache_peer(&PeerInfo::User { + id: user.id().bare_id_unchecked(), + auth: Some(auth), + bot: Some(user.is_bot()), + is_self: Some(true), + }) + .await?; + if let Some(tl::enums::updates::State::State(state)) = update_state { + self.0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + } + + Ok(user) + } + + /// Signs in to the bot account associated with this token. + /// + /// This is the method you need to call to use the client under a bot account. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // Obtain your own by talking to @BotFather via a Telegram app. + /// const TOKEN: &str = "776609994:AAFXAy5-PawQlnYywUlZ_b_GOXgarR3ah_yq"; + /// + /// let user = match client.bot_sign_in(TOKEN, API_HASH).await { + /// Ok(user) => user, + /// Err(err) => { + /// println!("Failed to sign in as a bot :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// + /// # Ok(()) + /// # } + /// ``` + pub async fn bot_sign_in(&self, token: &str, api_hash: &str) -> Result { + let request = tl::functions::auth::ImportBotAuthorization { + flags: 0, + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + bot_auth_token: token.to_string(), + }; + + let result = match self.invoke(&request).await { + Ok(x) => x, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + self.invoke(&request).await? + } + Err(e) => return Err(e.into()), + }; + + match result { + tl::enums::auth::Authorization::Authorization(x) => { + self.complete_login(x).await.map_err(Into::into) + } + tl::enums::auth::Authorization::SignUpRequired(_) => { + panic!("API returned SignUpRequired even though we're logging in as a bot"); + } + } + } + + /// Requests the login code for the account associated to the given phone + /// number via another Telegram application or SMS. + /// + /// This is the method you need to call before being able to sign in to a user account. + /// After you obtain the code and it's inside your program (e.g. ask the user to enter it + /// via the console's standard input), you will need to [`Client::sign_in`] to complete the + /// process. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // The phone used here does NOT need to be the same as the one used by the developer + /// // to obtain the API ID and hash. + /// const PHONE: &str = "+1 415 555 0132"; + /// + /// if !client.is_authorized().await? { + /// // We're not logged in, so request the login code. + /// client.request_login_code(PHONE, API_HASH).await?; + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn request_login_code( + &self, + phone: &str, + api_hash: &str, + ) -> Result { + let request = tl::functions::auth::SendCode { + phone_number: phone.to_string(), + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + settings: tl::types::CodeSettings { + allow_flashcall: false, + current_number: false, + allow_app_hash: false, + allow_missed_call: false, + allow_firebase: false, + logout_tokens: None, + token: None, + app_sandbox: None, + unknown_number: false, + } + .into(), + }; + + use tl::enums::auth::SentCode as SC; + + let sent_code: tl::types::auth::SentCode = match self.invoke(&request).await { + Ok(x) => match x { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + }, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + match self.invoke(&request).await? { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + } + } + Err(e) => return Err(e.into()), + }; + + Ok(LoginToken { + phone: phone.to_string(), + phone_code_hash: sent_code.phone_code_hash, + }) + } + + /// Signs in to the user account. + /// + /// You must call [`Client::request_login_code`] before using this method in order to obtain + /// necessary login token, and also have asked the user for the login code. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # use grammers_client::SignInError; + /// + /// async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn ask_code_to_user() -> String { + /// unimplemented!() + /// } + /// + /// let token = client.request_login_code(PHONE, API_HASH).await?; + /// let code = ask_code_to_user(); + /// + /// let user = match client.sign_in(&token, &code).await { + /// Ok(user) => user, + /// Err(SignInError::PasswordRequired(_token)) => panic!("Please provide a password"), + /// Err(SignInError::SignUpRequired) => panic!("Sign up required"), + /// Err(err) => { + /// println!("Failed to sign in as a user :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_in(&self, token: &LoginToken, code: &str) -> Result { + match self + .invoke(&tl::functions::auth::SignIn { + phone_number: token.phone.clone(), + phone_code_hash: token.phone_code_hash.clone(), + phone_code: Some(code.to_string()), + email_verification: None, + }) + .await + { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => { + Err(SignInError::SignUpRequired) + } + Err(err) if err.is("SESSION_PASSWORD_NEEDED") => { + let password_token = self.get_password_information().await; + match password_token { + Ok(token) => Err(SignInError::PasswordRequired(token)), + Err(e) => Err(SignInError::Other(e)), + } + } + Err(err) if err.is("PHONE_CODE_*") => Err(SignInError::InvalidCode), + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Extract information needed for the two-factor authentication + /// It's called automatically when we get SESSION_PASSWORD_NEEDED error during sign in. + async fn get_password_information(&self) -> Result { + let request = tl::functions::account::GetPassword {}; + + let password: tl::types::account::Password = self.invoke(&request).await?.into(); + + Ok(PasswordToken::new(password)) + } + + /// Sign in using two-factor authentication (user password). + /// + /// [`PasswordToken`] can be obtained from [`SignInError::PasswordRequired`] error after the + /// [`Client::sign_in`] method fails. + /// + /// # Examples + /// + /// ``` + /// use grammers_client::SignInError; + /// + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn get_user_password(hint: &str) -> Vec { + /// unimplemented!() + /// } + /// + /// # let token = client.request_login_code(PHONE, API_HASH).await?; + /// # let code = ""; + /// + /// // ... enter phone number, request login code ... + /// + /// let user = match client.sign_in(&token, &code).await { + /// Err(SignInError::PasswordRequired(password_token) ) => { + /// let mut password = get_user_password(password_token.hint().unwrap()); + /// + /// client + /// .check_password(password_token, password) + /// .await.unwrap() + /// } + /// Ok(user) => user, + /// Ok(_) => panic!("Sign in required"), + /// Err(err) => { + /// panic!("Failed to sign in as a user :(\n{err}"); + /// } + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn check_password( + &self, + password_token: PasswordToken, + password: impl AsRef<[u8]>, + ) -> Result { + let mut password_info = password_token.password; + let current_algo = password_info.current_algo.clone().unwrap(); + let mut params = utils::extract_password_parameters(¤t_algo); + + // Telegram sent us incorrect parameters, trying to get them again + if !check_p_and_g(params.2, params.3) { + password_info = self + .get_password_information() + .await + .map_err(SignInError::Other)? + .password; + params = + utils::extract_password_parameters(password_info.current_algo.as_ref().unwrap()); + if !check_p_and_g(params.2, params.3) { + panic!("Failed to get correct password information from Telegram") + } + } + + let (salt1, salt2, p, g) = params; + + let g_b = password_info.srp_b.clone().unwrap(); + let a = password_info.secure_random.clone(); + + let (m1, g_a) = calculate_2fa(salt1, salt2, p, g, g_b, a, password); + + let check_password = tl::functions::auth::CheckPassword { + password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp { + srp_id: password_info.srp_id.clone().unwrap(), + a: g_a.to_vec(), + m1: m1.to_vec(), + }), + }; + + match self.invoke(&check_password).await { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_x)) => panic!("Unexpected result"), + Err(err) if err.is("PASSWORD_HASH_INVALID") => { + Err(SignInError::InvalidPassword(PasswordToken { + password: password_info, + })) + } + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Signs out of the account authorized by this client's session. + /// + /// If the client was not logged in, this method returns false. + /// + /// The client is not disconnected after signing out. + /// + /// Note that after using this method you will have to sign in again. If all you want to do + /// is disconnect, simply [`drop`] the [`Client`] instance. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.sign_out().await.is_ok() { + /// println!("Signed out successfully!"); + /// } else { + /// println!("No user was signed in, so nothing has changed..."); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_out(&self) -> Result { + self.invoke(&tl::functions::auth::LogOut {}).await + } + + /// Signals all clients sharing the same sender pool to disconnect. + pub fn disconnect(&self) { + self.0.handle.quit(); + } +} diff --git a/vendor/grammers-client/src/client/bots.rs b/vendor/grammers-client/src/client/bots.rs new file mode 100644 index 0000000..2f9bdc0 --- /dev/null +++ b/vendor/grammers-client/src/client/bots.rs @@ -0,0 +1,205 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::PeerRef; +use grammers_tl_types as tl; + +use super::messages::parse_mention_entities; +use super::{Client, IterBuffer}; +use crate::message::InputMessage; +use crate::utils::generate_random_id; + +const MAX_LIMIT: usize = 50; + +/// Item returned by [`Client::inline_query`]. +pub struct InlineResult { + client: Client, + query_id: i64, + pub raw: tl::enums::BotInlineResult, +} + +/// Iterator returned by [`Client::inline_query`]. +pub type InlineResultIter = IterBuffer; + +impl InlineResult { + /// Send this inline result to the specified peer. + // TODO return the produced message + pub async fn send>(&self, peer: C) -> Result<(), InvocationError> { + self.client + .invoke(&tl::functions::messages::SendInlineBotResult { + silent: false, + background: false, + clear_draft: false, + hide_via: false, + peer: peer.into().into(), + reply_to: None, + random_id: generate_random_id(), + query_id: self.query_id, + id: self.id().to_string(), + schedule_date: None, + send_as: None, + quick_reply_shortcut: None, + allow_paid_stars: None, + }) + .await + .map(drop) + } + + /// The ID for this result. + pub fn id(&self) -> &str { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => &r.id, + BotInlineMediaResult(r) => &r.id, + } + } + + /// The title for this result, if any. + pub fn title(&self) -> Option<&String> { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => r.title.as_ref(), + BotInlineMediaResult(r) => r.title.as_ref(), + } + } +} + +impl InlineResultIter { + fn new(client: &Client, bot: PeerRef, query: &str) -> Self { + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetInlineBotResults { + bot: bot.into(), + peer: tl::enums::InputPeer::Empty, + geo_point: None, + query: query.to_string(), + offset: String::new(), + }, + ) + } + + /// Indicate the bot the peer where this inline query will be sent to. + /// + /// Some bots use this information to return different results depending on the type of the + /// peer, and some even "need" it to give useful results. + pub fn peer>(mut self, peer: C) -> Self { + self.request.peer = peer.into().into(); + self + } + + /// Return the next `InlineResult` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no results left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + let tl::enums::messages::BotResults::Results(tl::types::messages::BotResults { + query_id, + next_offset, + results, + .. + }) = self.client.invoke(&self.request).await?; + + if let Some(offset) = next_offset { + self.request.offset = offset; + } else { + self.last_chunk = true; + } + + let client = self.client.clone(); + self.buffer + .extend(results.into_iter().map(|r| InlineResult { + client: client.clone(), + query_id, + raw: r, + })); + + Ok(self.pop_item()) + } +} + +/// Method implementations related to dealing with bots. +impl Client { + /// Perform an inline query to the specified bot. + /// + /// The query text may be empty. + /// + /// Executing the query will fail if the input peer does not actually represent a bot account + /// supporting inline mode. + /// + /// # Examples + /// + /// ``` + /// # async fn f(bot: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This is equivalent to writing `@bot inline query` in a Telegram app. + /// let mut inline_results = client.inline_query(bot, "inline query"); + /// + /// while let Some(result) = inline_results.next().await? { + /// println!("{}", result.title().unwrap()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn inline_query>(&self, bot: C, query: &str) -> InlineResultIter { + InlineResultIter::new(self, bot.into(), query) + } + + /// Edits an inline message sent by a bot. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + pub(crate) async fn edit_inline_message( + &self, + message_id: tl::enums::InputBotInlineMessageId, + message: InputMessage, + ) -> Result { + let entities = parse_mention_entities(self, message.entities).await; + if message.media.as_ref().is_some_and(|media| { + !matches!( + media, + tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::DocumentExternal(_), + ) + }) { + let dc_id = message_id.dc_id(); + self.invoke_in_dc( + dc_id, + &tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }, + ) + .await + } else { + self.invoke(&tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }) + .await + } + } +} diff --git a/vendor/grammers-client/src/client/chats.rs b/vendor/grammers-client/src/client/chats.rs new file mode 100644 index 0000000..cfc0368 --- /dev/null +++ b/vendor/grammers-client/src/client/chats.rs @@ -0,0 +1,1013 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to users, groups and channels. + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::media::Photo; +use crate::message::Message; +use crate::peer::ActionSender; +use crate::peer::AdminRightsBuilder; +use crate::peer::BannedRightsBuilder; +use crate::peer::Participant; +use crate::peer::Peer; +use crate::peer::PeerMap; +use crate::peer::User; +use crate::peer::chats::AdminRightsBuilderInner; +use crate::peer::chats::BannedRightsBuilderInner; + +const MAX_PARTICIPANT_LIMIT: usize = 200; +const MAX_PHOTO_LIMIT: usize = 100; +const KICK_BAN_DURATION: i32 = 60; // in seconds, in case the second request fails + +enum ParticipantIterInner { + Empty, + Chat { + client: Client, + chat_id: i64, + buffer: VecDeque, + total: Option, + }, + Channel(IterBuffer), +} + +/// Iterator returned by [`Client::iter_participants`]. +pub struct ParticipantIter(ParticipantIterInner); + +impl ParticipantIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if peer.id.kind() == PeerKind::Channel { + ParticipantIterInner::Channel(IterBuffer::from_request( + client, + MAX_PARTICIPANT_LIMIT, + tl::functions::channels::GetParticipants { + channel: peer.into(), + filter: tl::enums::ChannelParticipantsFilter::ChannelParticipantsRecent, + offset: 0, + limit: 0, + hash: 0, + }, + )) + } else if peer.id.kind() == PeerKind::Chat { + ParticipantIterInner::Chat { + client: client.clone(), + chat_id: peer.into(), + buffer: VecDeque::new(), + total: None, + } + } else { + ParticipantIterInner::Empty + }) + } + + /// Determines how many participants there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { total, .. } => { + if let Some(total) = total { + Ok(*total) + } else { + self.fill_buffer().await + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { + client, + chat_id, + buffer, + total, + } => { + assert!(buffer.is_empty()); + let tl::enums::messages::ChatFull::Full(full) = client + .invoke(&tl::functions::messages::GetFullChat { chat_id: *chat_id }) + .await?; + + let chat = match full.full_chat { + tl::enums::ChatFull::Full(chat) => chat, + tl::enums::ChatFull::ChannelFull(_) => panic!( + "API returned ChannelFull even though messages::GetFullChat was used" + ), + }; + + // Don't actually care for the chats, just the users. + let mut peers = client.build_peer_map(full.users, Vec::new()).await; + + let participants = match chat.participants { + tl::enums::ChatParticipants::Forbidden(c) => { + if let Some(p) = c.self_participant { + buffer.push_back(Participant::from_raw_chat(&mut peers, p)); + } + return Ok(buffer.len()); + } + tl::enums::ChatParticipants::Participants(c) => c.participants, + }; + + buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_chat(&mut peers, p)), + ); + + *total = Some(buffer.len()); + Ok(buffer.len()) + } + ParticipantIterInner::Channel(iter) => { + assert!(iter.buffer.is_empty()); + use tl::enums::channels::ChannelParticipants::*; + + iter.request.limit = iter.determine_limit(MAX_PARTICIPANT_LIMIT); + let (count, participants, _, users) = + match iter.client.invoke(&iter.request).await? { + Participants(p) => (p.count, p.participants, p.chats, p.users), + NotModified => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + // Telegram can return less participants than asked for but the count being higher + // (for example, count=4825, participants=199, users=200). The missing participant + // was an admin bot account, not sure why it's not included. + // + // In any case we pick whichever size is highest to avoid weird cases like this. + iter.last_chunk = + usize::max(participants.len(), users.len()) < iter.request.limit as usize; + iter.request.offset += participants.len() as i32; + + // Don't actually care for the chats, just the users. + let mut peers = iter.client.build_peer_map(users, Vec::new()).await; + + iter.buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_channel(&mut peers, p)), + ); + + iter.total = Some(count as usize); + Ok(count as usize) + } + } + } + + /// Return the next `Participant` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no participants left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ParticipantIterInner::Empty => {} + ParticipantIterInner::Chat { buffer, .. } => { + if buffer.is_empty() { + self.fill_buffer().await?; + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + } + + match &mut self.0 { + ParticipantIterInner::Empty => Ok(None), + ParticipantIterInner::Chat { buffer, .. } => { + let result = buffer.pop_front(); + if buffer.is_empty() { + self.0 = ParticipantIterInner::Empty; + } + Ok(result) + } + ParticipantIterInner::Channel(iter) => Ok(iter.pop_item()), + } + } + + /// apply a filter on fetched participants, note that this filter will apply only on large `Channel` and not small groups + pub fn filter(mut self, filter: tl::enums::ChannelParticipantsFilter) -> Self { + match self.0 { + ParticipantIterInner::Channel(ref mut c) => { + c.request.filter = filter; + self + } + _ => self, + } + } +} + +enum ProfilePhotoIterInner { + User(IterBuffer), + Chat(IterBuffer), +} + +/// Iterator returned by [`Client::iter_profile_photos`]. +pub struct ProfilePhotoIter(ProfilePhotoIterInner); + +impl ProfilePhotoIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if matches!(peer.id.kind(), PeerKind::User) { + ProfilePhotoIterInner::User(IterBuffer::from_request( + client, + MAX_PHOTO_LIMIT, + tl::functions::photos::GetUserPhotos { + user_id: peer.into(), + offset: 0, + max_id: 0, + limit: 0, + }, + )) + } else { + ProfilePhotoIterInner::Chat( + client + .search_messages(peer) + .filter(tl::enums::MessagesFilter::InputMessagesFilterChatPhotos), + ) + }) + } + + /// Determines how many profile photos there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + ProfilePhotoIterInner::Chat(iter) => iter.total().await, + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + use tl::enums::photos::Photos; + + iter.request.limit = iter.determine_limit(MAX_PHOTO_LIMIT); + let (total, photos) = match iter.client.invoke(&iter.request).await? { + Photos::Photos(p) => { + iter.last_chunk = true; + iter.total = Some(p.photos.len()); + (p.photos.len(), p.photos) + } + Photos::Slice(p) => { + iter.last_chunk = p.photos.len() < iter.request.limit as usize; + iter.total = Some(p.count as usize); + (p.count as usize, p.photos) + } + }; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !iter.last_chunk && !photos.is_empty() { + iter.request.offset += photos.len() as i32; + } + + iter.buffer.extend(photos.into_iter().map(Photo::from_raw)); + + Ok(total) + } + ProfilePhotoIterInner::Chat(_) => { + panic!("fill_buffer should not be called for Chat variant") + } + } + } + + /// Return the next photo from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no photos left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + ProfilePhotoIterInner::Chat(iter) => { + while let Some(message) = iter.next().await? { + if let Some(tl::enums::MessageAction::ChatEditPhoto( + tl::types::MessageActionChatEditPhoto { photo }, + )) = message.action() + { + return Ok(Some(Photo::from_raw(photo.clone()))); + } else { + continue; + } + } + } + } + + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => Ok(iter.pop_item()), + ProfilePhotoIterInner::Chat(_) => Ok(None), + } + } +} + +fn updates_to_chat(client: &Client, id: Option, updates: tl::enums::Updates) -> Option { + use tl::enums::Updates; + + let chats = match updates { + Updates::Combined(updates) => Some(updates.chats), + Updates::Updates(updates) => Some(updates.chats), + _ => None, + }; + + match chats { + Some(chats) => match id { + Some(id) => chats.into_iter().find(|chat| chat.id() == id), + None => chats.into_iter().next(), + }, + None => None, + } + .map(|chat| Peer::from_raw(client, chat)) +} + +/// Method implementations related to dealing with peers. +impl Client { + /// Resolves a username into the peer that owns it, if any. + /// + /// Note that this method is expensive to call, and can quickly cause long flood waits. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(peer) = client.resolve_username("username").await? { + /// println!("Found peer!: {:?}", peer.name()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_username(&self, username: &str) -> Result, InvocationError> { + let tl::types::contacts::ResolvedPeer { peer, users, chats } = match self + .invoke(&tl::functions::contacts::ResolveUsername { + username: username.into(), + referer: None, + }) + .await + { + Ok(tl::enums::contacts::ResolvedPeer::Peer(p)) => p, + Err(err) if err.is("USERNAME_NOT_OCCUPIED") => return Ok(None), + Err(err) => return Err(err), + }; + + let mut peers = self.build_peer_map(users, chats).await; + let peer_id = PeerId::from(peer); + + Ok(peers.take(peer_id)) + } + + /// Fetch full information about the currently logged-in user. + /// + /// Although this method is cheap to call, you might want to cache the results somewhere. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// println!("Displaying full user information of the logged-in user:"); + /// dbg!(client.get_me().await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_me(&self) -> Result { + let mut res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![tl::enums::InputUser::UserSelf], + }) + .await?; + + if res.len() != 1 { + panic!("fetching only one user should exactly return one user"); + } + + Ok(User::from_raw(self, res.pop().unwrap())) + } + + /// Iterate over the participants of a chat. + /// + /// The participants are returned in no particular order. + /// + /// When used to iterate the participants of "user", the iterator won't produce values. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut participants = client.iter_participants(chat); + /// + /// while let Some(participant) = participants.next().await? { + /// println!( + /// "{} has role {:?}", + /// participant.user.first_name().unwrap_or(&participant.user.id().to_string()), + /// participant.role + /// ); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_participants>(&self, chat: C) -> ParticipantIter { + ParticipantIter::new(self, chat.into()) + } + + /// Kicks the participant from the chat. + /// + /// This will fail if you do not have sufficient permissions to perform said operation, + /// or the target user is the logged-in account. Use [`Self::delete_dialog`] for the latter instead. + /// + /// The kicked user will be able to join after being kicked (they are not permanently banned). + /// + /// Kicking someone who was not in the chat prior to running this method will be able to join + /// after as well (effectively unbanning them). + /// + /// When used to kick users from "user" chat, nothing will be done. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// match client.kick_participant(chat, user).await { + /// Ok(_) => println!("user is no more >:D"), + /// Err(_) => println!("Kick failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn kick_participant, U: Into>( + &self, + chat: C, + user: U, + ) -> Result<(), InvocationError> { + let chat = chat.into(); + let user = user.into(); + if chat.id.kind() == PeerKind::Channel { + self.set_banned_rights(chat, user) + .view_messages(false) + .duration(Duration::from_secs(KICK_BAN_DURATION as u64)) + .await?; + + self.set_banned_rights(chat, user).await + } else if chat.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: chat.into(), + user_id: user.into(), + revoke_history: false, + }) + .await + .map(drop) + } else { + Ok(()) + } + } + + /// Returns a builder to set the banned rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point it might result in + /// error if you do not have sufficient permissions to ban the user in the input chat. + /// + /// By default, the user has all rights, and you need to revoke those you want to take away + /// from the user by setting the permissions to `false`. This means that not taking away any + /// permissions will effectively unban someone, granting them all default user permissions. + /// + /// By default, the ban is applied forever, but this can be changed to a shorter duration. + /// + /// The default group rights are respected, despite individual restrictions. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This user keeps spamming pepe stickers, take the sticker permission away from them + /// let res = client + /// .set_banned_rights(chat, user) + /// .send_stickers(false) + /// .await; + /// + /// match res { + /// Ok(_) => println!("No more sticker spam!"), + /// Err(_) => println!("Ban failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub fn set_banned_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> BannedRightsBuilder>> { + BannedRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + BannedRightsBuilderInner::invoke, + ) + } + + /// Returns a builder to set the administrator rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point + /// it might result in error if you do not have sufficient permissions to grant those rights + /// to the other user. + /// + /// By default, no permissions are granted, and you need to specify those you want to grant by + /// setting the permissions to `true`. This means that not granting any permission will turn + /// the user into a normal user again, and they will no longer be an administrator. + /// + /// The change is applied forever and there is no way to set a specific duration. If the user + /// should only be an administrator for a set period of time, the administrator permissions + /// must be manually revoked at a later point in time. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let the user pin messages and ban other people + /// let res = client.set_admin_rights(chat, user) + /// .load_current() + /// .await? + /// .pin_messages(true) + /// .ban_users(true) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set_admin_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> AdminRightsBuilder>> { + AdminRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + AdminRightsBuilderInner::invoke, + ) + } + + /// Iterate over the history of profile photos for the given peer. + /// + /// Note that the current photo might not be present in the history, and to avoid doing more + /// work when it's generally not needed (the photo history tends to be complete but in some + /// cases it might not be), it's up to you to fetch this photo from the full channel. + /// + /// Note that you cannot use these photos to send them as messages directly. They must be + /// downloaded first, then uploaded, and finally sent. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut photos = client.iter_profile_photos(peer); + /// + /// while let Some(photo) = photos.next().await? { + /// println!("Did you know peer has a photo with ID {}?", photo.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_profile_photos>(&self, peer: C) -> ProfilePhotoIter { + ProfilePhotoIter::new(self, peer.into()) + } + + /// Convert a [`PeerRef`] back into a [`Peer`]. + /// + /// # Example + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let peer = client.resolve_peer(peer).await?; + /// + /// println!("Found peer: {}", peer.name().unwrap_or(&peer.id().to_string())); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_peer>(&self, peer: C) -> Result { + let peer = peer.into(); + let mut peers = match peer.id.kind() { + PeerKind::User => { + let res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![peer.into()], + }) + .await?; + if res.len() > 1 { + panic!("fetching only one user should exactly return one user"); + } + self.build_peer_map(res, Vec::new()).await + } + PeerKind::Chat => { + let res = match self + .invoke(&tl::functions::messages::GetChats { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one chat should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + PeerKind::Channel => { + let res = match self + .invoke(&tl::functions::channels::GetChannels { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one channel should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + }; + peers.take(peer.id).ok_or(InvocationError::Dropped) + } + + /// Get permissions of participant `user` from chat `chat`. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let permissions = client.get_permissions(chat, user).await?; + /// println!("The user {} an admin", if permissions.is_admin() { "is" } else { "is not" }); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_permissions, U: Into>( + &self, + chat: C, + user: U, + ) -> Result { + let chat = chat.into(); + let user = user.into(); + + // Get by chat + if chat.id.kind() == PeerKind::Chat { + // Get user id + let user = user.into(); + let user_id = match user { + tl::enums::InputUser::User(user) => user.user_id, + tl::enums::InputUser::FromMessage(user) => user.user_id, + tl::enums::InputUser::UserSelf => { + let me = self.get_me().await?; + me.id().bare_id_unchecked() + } + tl::enums::InputUser::Empty => return Err(InvocationError::Dropped), + }; + + // Get chat and find user + let chat = self + .invoke(&tl::functions::messages::GetFullChat { + chat_id: chat.into(), + }) + .await?; + let tl::enums::messages::ChatFull::Full(chat) = chat; + if let tl::enums::ChatFull::Full(chat) = chat.full_chat { + if let tl::enums::ChatParticipants::Participants(participants) = chat.participants { + for participant in participants.participants { + if participant.user_id() == user_id { + return Ok(ParticipantPermissions(ParticipantPermissionsInner::Chat( + participant, + ))); + } + } + } + } + return Err(InvocationError::Rpc(RpcError { + code: 400, + name: "USER_NOT_PARTICIPANT".to_string(), + value: None, + caused_by: None, + })); + } + + // Get by channel + let participant = self + .invoke(&tl::functions::channels::GetParticipant { + channel: chat.into(), + participant: user.into(), + }) + .await?; + let tl::enums::channels::ChannelParticipant::Participant(participant) = participant; + Ok(ParticipantPermissions( + ParticipantPermissionsInner::Channel(participant.participant), + )) + } + + #[cfg(feature = "parse_invite_link")] + pub fn parse_invite_link(invite_link: &str) -> Option { + let url_parse_result = url::Url::parse(invite_link); + if url_parse_result.is_err() { + return None; + } + + let url_parse = url_parse_result.unwrap(); + let scheme = url_parse.scheme(); + let path = url_parse.path(); + if url_parse.host_str().is_none() || !vec!["https", "http"].contains(&scheme) { + return None; + } + let host = url_parse.host_str().unwrap(); + let hosts = [ + "t.me", + "telegram.me", + "telegram.dog", + "tg.dev", + "telegram.me", + "telesco.pe", + ]; + + if !hosts.contains(&host) { + return None; + } + let paths = path.split("/").skip(1).collect::>(); + + if paths.len() == 1 { + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + if paths.len() > 1 { + if paths[0].starts_with("joinchat") { + return Some(paths[1].to_string()); + } + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + None + } + + /// Accept an invite link to join the corresponding private chat. + /// + /// If the chat is public (has a public username), [`Client::join_chat`](Client::join_chat) should be used instead. + #[cfg(feature = "parse_invite_link")] + pub async fn accept_invite_link( + &self, + invite_link: &str, + ) -> Result, InvocationError> { + match Self::parse_invite_link(invite_link) { + Some(hash) => Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::messages::ImportChatInvite { hash }) + .await? + { + updates_to_chat(self, None, ok.updates) + } else { + None + }, + ), + None => Err(InvocationError::Rpc(RpcError { + code: 400, + name: "INVITE_HASH_INVALID".to_string(), + value: None, + caused_by: None, + })), + } + } + + #[cfg_attr( + not(feature = "parse_invite_link"), + allow(rustdoc::broken_intra_doc_links) + )] + /// Join a public group or channel. + /// + /// A channel is public if it has a username. + /// To join private chats, [`Client::accept_invite_link`](Client::accept_invite_link) should be used instead. + pub async fn join_chat>( + &self, + chat: C, + ) -> Result, InvocationError> { + let chat: PeerRef = chat.into(); + let channel = chat.into(); + Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::channels::JoinChannel { channel }) + .await? + { + updates_to_chat(self, chat.id.bare_id(), ok.updates) + } else { + None + }, + ) + } + + /// Send a message action (such as typing, uploading photo, or viewing an emoji interaction) + /// + /// # Examples + /// + /// **Do a one-shot pulse and let it fade away** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types::enums::SendMessageAction; + /// + /// client + /// .action(peer) + /// .oneshot(SendMessageAction::SendMessageTypingAction) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// **Repeat request until the future is done** + /// ``` + /// # use std::time::Duration; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// let heavy_task = async { + /// tokio::time::sleep(Duration::from_secs(10)).await; + /// + /// 42 + /// }; + /// + /// tokio::pin!(heavy_task); + /// + /// let (task_result, _) = client + /// .action(peer) + /// .repeat( + /// // most clients doesn't actually show progress of an action + /// || tl::types::SendMessageUploadDocumentAction { progress: 0 }, + /// heavy_task + /// ) + /// .await; + /// + /// // Note: repeat function does not cancel actions automatically, they will just fade away + /// + /// assert_eq!(task_result, 42); + /// # Ok(()) + /// # } + /// ``` + /// + /// **Cancel any actions** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.action(peer).cancel().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn action>(&self, peer: C) -> ActionSender { + ActionSender::new(self, peer) + } + + pub(crate) async fn build_peer_map( + &self, + users: Vec, + chats: Vec, + ) -> PeerMap { + let map = users + .into_iter() + .map(|user| Peer::from_user(self, user)) + .chain(chats.into_iter().map(|chat| Peer::from_raw(self, chat))) + .map(|peer| (peer.id(), peer)) + .collect::>(); + + if self.0.configuration.auto_cache_peers { + for peer in map.values() { + if peer.auth().is_some() { + if let Err(e) = self.0.session.cache_peer(&peer.into()).await { + log::warn!("cache_peer fail: {:?}", e) + } + } + } + } + + PeerMap { + map: Arc::new(map), + session: Arc::clone(&self.0.session), + } + } + + pub(crate) fn empty_peer_map(&self) -> PeerMap { + PeerMap { + map: Arc::new(HashMap::new()), + session: Arc::clone(&self.0.session), + } + } +} + +#[derive(Debug, Clone)] +enum ParticipantPermissionsInner { + Channel(tl::enums::ChannelParticipant), + Chat(tl::enums::ChatParticipant), +} + +/// Permissions returned by [`Client::get_permissions`]. +#[derive(Debug, Clone)] +pub struct ParticipantPermissions(ParticipantPermissionsInner); + +impl ParticipantPermissions { + /// Whether the user is the creator of the chat or not. + pub fn is_creator(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Creator(_)) + ) + } + + /// Whether the user is an administrator of the chat or not. The creator also counts as begin an administrator, since they have all permissions. + pub fn is_admin(&self) -> bool { + self.is_creator() + || matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Admin(_)) + ) + } + + /// Whether the user is banned in the chat. + pub fn is_banned(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Banned(_)) + ) + } + + /// Whether the user left the chat. + pub fn has_left(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Left(_)) + ) + } + + /// Whether the user is a normal user of the chat (not administrator, but not banned either, and has no restrictions applied). + pub fn has_default_permissions(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Participant(_)) + | ParticipantPermissionsInner::Channel( + tl::enums::ChannelParticipant::ParticipantSelf(_) + ) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Participant(_)) + ) + } + + /// Whether the administrator can add new administrators with the same or less permissions than them. + pub fn can_add_admins(&self) -> bool { + if !self.is_admin() { + return false; + } + match &self.0 { + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin( + participant, + )) => { + let tl::enums::ChatAdminRights::Rights(rights) = &participant.admin_rights; + rights.add_admins + } + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) => true, + ParticipantPermissionsInner::Chat(_) => self.is_creator(), + _ => false, + } + } +} diff --git a/vendor/grammers-client/src/client/client.rs b/vendor/grammers-client/src/client/client.rs new file mode 100644 index 0000000..b16614b --- /dev/null +++ b/vendor/grammers-client/src/client/client.rs @@ -0,0 +1,105 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::sync::Arc; + +use grammers_mtsender::SenderPoolHandle; +use grammers_session::ErasedSession; + +pub(crate) struct ClientInner { + pub(crate) session: Arc, + pub(crate) api_id: i32, + pub(crate) handle: SenderPoolHandle, + pub(crate) configuration: ClientConfiguration, + pub(crate) auth_copied_to_dcs: tokio::sync::Mutex>, +} + +/// Wrapper around [`SenderPool`] to facilitate interaction with Telegram's API. +/// +/// This structure is the "entry point" of the library, from which you can start using the rest. +/// +/// This structure owns all the necessary connections to Telegram, and has implementations for the +/// most basic methods, such as connecting, signing in, or processing network events. +/// +/// On drop, all state is synchronized to the session. The [`Session`] must be explicitly saved +/// to disk with its corresponding save method for persistence. +/// +/// [`SenderPool`]: grammers_mtsender::SenderPool +/// [`Session`]: grammers_session::Session +#[derive(Clone)] +pub struct Client(pub(crate) Arc); + +use std::time::Duration; + +/// Configuration that controls the [`Client`] behaviour when making requests. +pub struct ClientConfiguration { + /// The retry policy to use when encountering errors after invoking a request. + pub retry_policy: Box, + + /// Whether to call [`Session::cache_peer`] on all peer information that + /// the high-level methods receive as a response (e.g. [`Client::iter_dialogs`]). + /// + /// The cached peers are then usable by other methods such as [`Client::resolve_peer`] + /// for as long as the same persisted session is used. + pub auto_cache_peers: bool, +} + +/// Configuration that controls [`Client::stream_updates`]. +pub struct UpdatesConfiguration { + /// Should the client catch-up on updates sent to it while it was offline? + /// + /// By default, updates sent while the client was offline are ignored. + pub catch_up: bool, + + /// How many updates may be buffered by the client at any given time. + /// + /// Telegram passively sends updates to the client through the open connection, so they must + /// be buffered until the application has the capacity to consume them. + /// + /// Upon reaching this limit, updates will be dropped, and a warning log message will be + /// emitted (but not too often, to avoid spamming the log), in order to let the developer + /// know that they should either change how they handle updates or increase the limit. + /// + /// A limit of zero (`Some(0)`) indicates that updates should not be buffered. + /// They will be immediately dropped, and no warning will ever be emitted. + /// + /// A limit of `None` disables the upper bound for the buffer. This is not recommended, as it + /// could eventually lead to memory exhaustion. This option will also not emit any warnings. + /// + /// The default limit, which may change at any time, should be enough for user accounts, + /// although bot accounts may need to increase the limit depending on their capacity. + /// + /// When the limit is `Some`, a buffer to hold that many updates will be pre-allocated. + pub update_queue_limit: Option, +} + +impl Default for ClientConfiguration { + /// Returns an instance that with an [`AutoSleep::default`] retry policy, + /// where encountered peers are automatically passed to [`Session::cache_peer`]. + /// + /// [`AutoSleep::default`]: super::AutoSleep::default + fn default() -> Self { + Self { + retry_policy: Box::new(super::AutoSleep { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + }), + auto_cache_peers: true, + } + } +} + +impl Default for UpdatesConfiguration { + /// Returns an instance that will not catch up, with a queue limit of 100 updates. + fn default() -> Self { + Self { + catch_up: false, + update_queue_limit: Some(100), + } + } +} diff --git a/vendor/grammers-client/src/client/dialogs.rs b/vendor/grammers-client/src/client/dialogs.rs new file mode 100644 index 0000000..dbc6df6 --- /dev/null +++ b/vendor/grammers-client/src/client/dialogs.rs @@ -0,0 +1,263 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerKind, PeerRef, UpdateState}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::peer::Dialog; + +const MAX_LIMIT: usize = 100; + +/// Iterator returned by [`Client::iter_dialogs`]. +pub type DialogIter = IterBuffer; + +impl DialogIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetDialogs { + exclude_pinned: false, + folder_id: None, + offset_date: 0, + offset_id: 0, + offset_peer: tl::enums::InputPeer::Empty, + limit: 0, + hash: 0, + }, + ) + } + + /// Determines how many dialogs there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Dialogs; + + self.request.limit = 1; + let total = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(dialogs) => dialogs.dialogs.len(), + Dialogs::Slice(dialogs) => dialogs.count as usize, + Dialogs::NotModified(dialogs) => dialogs.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Return the next `Dialog` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no dialogs left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + use tl::enums::messages::Dialogs; + + self.request.limit = self.determine_limit(MAX_LIMIT); + let (dialogs, mut messages, users, chats) = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(d) => { + self.last_chunk = true; + self.total = Some(d.dialogs.len()); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::Slice(d) => { + self.last_chunk = d.dialogs.len() < self.request.limit as usize; + self.total = Some(d.count as usize); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::NotModified(_) => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + let peers = self.client.build_peer_map(users, chats).await; + + for dialog in dialogs.iter() { + if let tl::enums::Dialog::Dialog(tl::types::Dialog { + peer: tl::enums::Peer::Channel(channel), + pts: Some(pts), + .. + }) = &dialog + { + self.client + .0 + .session + .set_update_state(UpdateState::Channel { + id: channel.channel_id, + pts: *pts, + }) + .await?; + } + } + + self.buffer.extend( + dialogs + .into_iter() + .map(|dialog| Dialog::new(&self.client, dialog, &mut messages, peers.handle())), + ); + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + self.request.exclude_pinned = true; + if let Some(last_message) = self + .buffer + .iter() + .rev() + .find_map(|dialog| dialog.last_message.as_ref()) + { + self.request.offset_date = last_message.date_timestamp(); + self.request.offset_id = last_message.id(); + } + self.request.offset_peer = self.buffer[self.buffer.len() - 1].peer_ref().into(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to open conversations. +impl Client { + /// Returns a new iterator over the dialogs. + /// + /// While iterating, the update state for any broadcast channel or megagroup will be set if it was unknown before. + /// When the update state is set for these peers, the library can actively check to make sure it's not missing any + /// updates from them (as long as the queue limit for updates is larger than zero). + /// + /// Bot accounts will receive an error response when using this method, as they do not have dialogs. + /// The closest a bot account can get to listing the peers it has interacted with is by querying the + /// session cache, but that approach can be misleading, as it often contains both additional peers, + /// and peers that have since blocked the bot ("deleted the dialog" with it). + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// let mut dialogs = client.iter_dialogs(); + /// + /// while let Some(dialog) = dialogs.next().await? { + /// let peer = dialog.peer(); + /// println!("{} ({})", peer.name().unwrap_or_default(), peer.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_dialogs(&self) -> DialogIter { + DialogIter::new(self) + } + + /// Deletes a dialog, effectively removing it from your list of open conversations. + /// + /// The dialog is only deleted for yourself. + /// + /// Deleting a dialog effectively clears the message history and "kicks" you from it. + /// + /// For groups and channels, this is the same as leaving said chat. This method does **not** + /// delete the chat itself (the chat still exists and the other members will remain inside). + /// + /// Bot accounts can use this method to leave a channel or group, but attempting + /// to leave the dialog with a user will fail, as bots do not actually have dialogs. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Consider making a backup before, you will lose access to the messages in peer! + /// client.delete_dialog(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_dialog>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::LeaveChannel { + channel: peer.into(), + }) + .await + .map(drop) + } else if peer.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: peer.into(), + user_id: tl::enums::InputUser::UserSelf, + revoke_history: false, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::DeleteHistory { + just_clear: false, + revoke: false, + peer: peer.into(), + max_id: 0, + min_date: None, + max_date: None, + }) + .await + .map(drop) + } + } + + /// Mark a peer as read. + /// + /// If you want to get rid of all the mentions (for example, a voice note that you have not + /// listened to yet), you need to also use [`Client::clear_mentions`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.mark_as_read(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn mark_as_read>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } + } + + /// Clears all pending mentions from a peer, marking them as read. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.clear_mentions(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn clear_mentions>(&self, peer: C) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::ReadMentions { + peer: peer.into().into(), + top_msg_id: None, + }) + .await + .map(drop) + } +} diff --git a/vendor/grammers-client/src/client/files.rs b/vendor/grammers-client/src/client/files.rs new file mode 100644 index 0000000..f076b65 --- /dev/null +++ b/vendor/grammers-client/src/client/files.rs @@ -0,0 +1,578 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::mem; +use std::sync::Arc; +#[cfg(feature = "fs")] +use std::{io::SeekFrom, path::Path}; + +use futures_util::stream::{FuturesUnordered, StreamExt as _}; +use grammers_mtsender::InvocationError; +use grammers_tl_types as tl; +use tokio::io::{self, AsyncRead, AsyncReadExt}; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(feature = "fs")] +use tokio::{ + fs, + io::{AsyncSeekExt, AsyncWriteExt}, + sync::mpsc::unbounded_channel, +}; + +use super::Client; +use crate::media::{Downloadable, Uploaded}; +use crate::utils::generate_random_id; + +pub const MIN_CHUNK_SIZE: i32 = 4 * 1024; +pub const MAX_CHUNK_SIZE: i32 = 512 * 1024; +const FILE_MIGRATE_ERROR: i32 = 303; +const BIG_FILE_SIZE: usize = 10 * 1024 * 1024; +const WORKER_COUNT: usize = 4; + +/// Iterator returned by [`Client::iter_download`]. +pub struct DownloadIter { + client: Client, + done: bool, + size: Option, + variant: DownloadIterVariant, +} + +enum DownloadIterVariant { + Request(tl::functions::upload::GetFile), + PreDownloaded(Vec), + PreFailed(io::Error), + Empty, +} + +impl DownloadIter { + /// Changes the chunk size, in bytes, used to make requests. Useful if you only need to get a + /// small part of a file. By default, `MAX_CHUNK_SIZE` is used. + /// + /// # Panics + /// + /// Panics if `size` is not divisible by `MIN_CHUNK_SIZE`, or if `size` is not in contained in + /// the range `MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE`. + pub fn chunk_size(mut self, size: i32) -> Self { + assert!((MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&size) && size % MIN_CHUNK_SIZE == 0); + match &mut self.variant { + DownloadIterVariant::Request(request) => request.limit = size, + _ => {} + } + self + } + + /// Skips `n` chunks to start downloading a different offset from the file. If you want to + /// skip less data, modify the `chunk_size` before calling this method, and then reset it to + /// any value you want. + pub fn skip_chunks(mut self, n: i32) -> Self { + match &mut self.variant { + DownloadIterVariant::Request(request) => { + request.offset += request.limit as i64 * (n as i64) + } + _ => {} + } + self + } + + /// Fetch and return the next chunk. + pub async fn next(&mut self) -> Result>, InvocationError> { + if self.done { + return Ok(None); + } + + let variant = mem::replace(&mut self.variant, DownloadIterVariant::Empty); + + let mut request = match variant { + DownloadIterVariant::Request(r) => r, + DownloadIterVariant::PreDownloaded(data) => { + self.done = true; + return Ok(Some(data.clone())); + } + DownloadIterVariant::PreFailed(error) => { + return Err(InvocationError::Io(error)); + } + DownloadIterVariant::Empty => return Ok(None), + }; + + use tl::enums::upload::File; + + // TODO handle maybe FILEREF_UPGRADE_NEEDED + let mut dc = self.client.0.session.home_dc_id()?; + loop { + break match self.client.invoke_in_dc(dc, &request).await { + Ok(File::File(f)) => { + let reached_known_size = self + .size + .is_some_and(|size| request.offset as usize + f.bytes.len() >= size); + + if reached_known_size || f.bytes.len() < request.limit as usize { + self.done = true; + if f.bytes.is_empty() { + break Ok(None); + } + } + + request.offset += request.limit as i64; + self.variant = DownloadIterVariant::Request(request); + + Ok(Some(f.bytes)) + } + Ok(File::CdnRedirect(_)) => { + panic!("API returned File::CdnRedirect even though cdn_supported = false"); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match self.client.copy_auth_to_dc(dc).await { + Ok(_) => continue, + Err(e) => Err(e), + } + } + Err(InvocationError::Rpc(err)) if err.code == FILE_MIGRATE_ERROR => { + dc = err.value.unwrap() as _; + continue; + } + Err(e) => Err(e), + }; + } + } +} + +/// Method implementations related to uploading or downloading files. +impl Client { + /// Returns a new iterator over the contents of a media document that will be downloaded. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// let mut file_bytes = Vec::new(); + /// let mut download = client.iter_download(&downloadable); + /// + /// while let Some(chunk) = download.next().await? { + /// file_bytes.extend(chunk); + /// } + /// + /// // The file is now downloaded in-memory, inside `file_bytes`! + /// # Ok(()) + /// # } + /// ``` + pub fn iter_download(&self, downloadable: &D) -> DownloadIter { + if let Some(data) = downloadable.to_data() { + DownloadIter { + client: self.clone(), + done: false, + size: Some(data.len()), + variant: DownloadIterVariant::PreDownloaded(data), + } + } else if let Some(location) = downloadable.to_raw_input_location() { + DownloadIter { + client: self.clone(), + done: false, + size: downloadable.size(), + variant: DownloadIterVariant::Request(tl::functions::upload::GetFile { + precise: false, + cdn_supported: false, + location, + offset: 0, + limit: MAX_CHUNK_SIZE, + }), + } + } else { + DownloadIter { + client: self.clone(), + done: false, + size: None, + variant: DownloadIterVariant::PreFailed(io::Error::new( + io::ErrorKind::Other, + "media not downloadable", + )), + } + } + } + + /// Downloads a media file into the specified path. + /// + /// If the file already exists, it will be overwritten. + /// + /// This is a small wrapper around [`Client::iter_download`] for the common case of + /// wanting to save the file locally. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// client.download_media(&downloadable, "/home/username/photos/holidays.jpg").await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "fs")] + pub async fn download_media>( + &self, + downloadable: &D, + path: P, + ) -> Result<(), InvocationError> { + // Concurrent downloader + if let Some((location, size)) = downloadable + .to_raw_input_location() + .zip(downloadable.size()) + { + if size > BIG_FILE_SIZE { + return self + .download_media_concurrent(location, size, path, WORKER_COUNT) + .await; + } + } + + let mut download = self.iter_download(downloadable); + Ok(Client::load(path, &mut download).await?) + } + + #[cfg(feature = "fs")] + async fn load>(path: P, download: &mut DownloadIter) -> Result<(), io::Error> { + let mut file = fs::File::create(path).await?; + while let Some(chunk) = download.next().await.map_err(io::Error::other)? { + file.write_all(&chunk).await?; + } + + Ok(()) + } + + /// Downloads a `Document` to specified path using multiple connections + #[cfg(feature = "fs")] + async fn download_media_concurrent>( + &self, + location: tl::enums::InputFileLocation, + size: usize, + path: P, + workers: usize, + ) -> Result<(), InvocationError> { + // Allocate + let mut file = fs::File::create(path).await.map_err(InvocationError::Io)?; + file.set_len(size as u64) + .await + .map_err(InvocationError::Io)?; + file.seek(SeekFrom::Start(0)) + .await + .map_err(InvocationError::Io)?; + + // Start workers + let (tx, mut rx) = unbounded_channel(); + let part_index = Arc::new(tokio::sync::Mutex::::new(0)); + let mut tasks = vec![]; + let home_dc_id = self.0.session.home_dc_id()?; + for _ in 0..workers { + let location = location.clone(); + let tx = tx.clone(); + let part_index = part_index.clone(); + let client = self.clone(); + let task = tokio::task::spawn(async move { + let mut retry_offset = None; + let mut dc = home_dc_id; + loop { + // Calculate file offset + let offset: i64 = { + if let Some(offset) = retry_offset { + retry_offset = None; + offset + } else { + let mut i = part_index.lock().await; + *i += 1; + MAX_CHUNK_SIZE as i64 * (*i - 1) + } + }; + if offset >= size as i64 { + break; + } + // Fetch from telegram + let request = &tl::functions::upload::GetFile { + precise: true, + cdn_supported: false, + location: location.clone(), + offset, + limit: MAX_CHUNK_SIZE, + }; + match client.invoke_in_dc(dc, request).await { + Ok(tl::enums::upload::File::File(file)) => { + tx.send((offset as u64, file.bytes)).unwrap(); + } + Ok(tl::enums::upload::File::CdnRedirect(_)) => { + panic!( + "API returned File::CdnRedirect even though cdn_supported = false" + ); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match client.copy_auth_to_dc(dc).await { + Ok(_) => { + retry_offset = Some(offset); + continue; + } + Err(e) => return Err(e), + } + } + Err(InvocationError::Rpc(err)) => { + if err.code == FILE_MIGRATE_ERROR { + dc = err.value.unwrap() as _; + retry_offset = Some(offset); + continue; + } + return Err(InvocationError::Rpc(err)); + } + Err(e) => return Err(e), + } + } + Ok::<(), InvocationError>(()) + }); + tasks.push(task); + } + drop(tx); + + // File write loop + let mut pos = 0; + while let Some((offset, data)) = rx.recv().await { + if offset != pos { + file.seek(SeekFrom::Start(offset)).await?; + } + file.write_all(&data).await?; + pos = offset + data.len() as u64; + } + + // Check if all tasks finished succesfully + for task in tasks { + let res = task.await.map_err(io::Error::other)?; + res?; + } + Ok(()) + } + + /// Uploads an async stream to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// The stream size must be known beforehand. If this is not possible, you might need to + /// process the entire async stream to determine its size, and then use the size and the + /// downloaded buffer. + /// + /// The stream size may be less or equal to the actual length of the stream, but not more. + /// If it's less, you may continue to read from the stream after the method returns. + /// If it's more, the method will fail because it does not have enough data to read. + /// + /// Note that Telegram uses the file name in certain methods, for example, to make sure the + /// file is an image when trying to use send the file as photo media, so it is important that + /// the file name at least uses the right extension, even if the name is a dummy value. + /// If the input file name is empty, the non-empty dummy value "a" will be used instead. + /// Because it has no extension, you may not be able to use the file in certain methods. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client, some_vec: &[u8]) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// // In-memory `Vec` buffers can be used as async streams + /// let size = some_vec.len(); + /// let mut stream = std::io::Cursor::new(some_vec); + /// let uploaded_file = client.upload_stream(&mut stream, size, "sleep.jpg".to_string()).await?; + /// + /// client.send_message(peer, InputMessage::new().text("Zzz...").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + pub async fn upload_stream( + &self, + stream: &mut S, + size: usize, + name: String, + ) -> Result { + let file_id = generate_random_id(); + let name = if name.is_empty() { + "a".to_string() + } else { + name + }; + + let big_file = size > BIG_FILE_SIZE; + let parts = PartStream::new(stream, size); + let total_parts = parts.total_parts(); + + if big_file { + let parts = Arc::new(parts); + let mut tasks = FuturesUnordered::new(); + for _ in 0..WORKER_COUNT { + let handle = self.clone(); + let parts = Arc::clone(&parts); + let task = async move { + while let Some((part, bytes)) = parts.next_part().await? { + let ok = handle + .invoke(&tl::functions::upload::SaveBigFilePart { + file_id, + file_part: part, + file_total_parts: total_parts, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(()) + }; + tasks.push(task); + } + + while let Some(res) = tasks.next().await { + res?; + } + + Ok(Uploaded::from_raw( + tl::types::InputFileBig { + id: file_id, + parts: total_parts, + name, + } + .into(), + )) + } else { + let mut md5 = md5::Context::new(); + while let Some((part, bytes)) = parts.next_part().await? { + md5.consume(&bytes); + let ok = self + .invoke(&tl::functions::upload::SaveFilePart { + file_id, + file_part: part, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(Uploaded::from_raw( + tl::types::InputFile { + id: file_id, + parts: total_parts, + name, + md5_checksum: format!("{:x}", md5.finalize()), + } + .into(), + )) + } + } + + /// Uploads a local file to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// If you need more control over the uploaded data, such as performing only a partial upload + /// or with a different name, use [`Client::upload_stream`] instead. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let uploaded_file = client.upload_file("/home/username/photos/holidays.jpg").await?; + /// + /// client.send_message(peer, InputMessage::new().text("Check this out!").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + #[cfg(feature = "fs")] + pub async fn upload_file>(&self, path: P) -> Result { + let path = path.as_ref(); + + let mut file = fs::File::open(path).await?; + let size = file.seek(SeekFrom::End(0)).await? as usize; + file.seek(SeekFrom::Start(0)).await?; + + // File name will only be `None` for `..` path, and directories cannot be uploaded as + // files, so it's fine to unwrap. + let name = path.file_name().unwrap().to_string_lossy().to_string(); + + self.upload_stream(&mut file, size, name).await + } +} + +struct PartStreamInner<'a, S: AsyncRead + Unpin> { + stream: &'a mut S, + current_part: i32, +} + +struct PartStream<'a, S: AsyncRead + Unpin> { + inner: AsyncMutex>, + total_parts: i32, +} + +impl<'a, S: AsyncRead + Unpin> PartStream<'a, S> { + fn new(stream: &'a mut S, size: usize) -> Self { + let total_parts = ((size + MAX_CHUNK_SIZE as usize - 1) / MAX_CHUNK_SIZE as usize) as i32; + Self { + inner: AsyncMutex::new(PartStreamInner { + stream, + current_part: 0, + }), + total_parts, + } + } + + fn total_parts(&self) -> i32 { + self.total_parts + } + + async fn next_part(&self) -> Result)>, io::Error> { + let mut lock = self.inner.lock().await; + if lock.current_part >= self.total_parts { + return Ok(None); + } + let mut read = 0; + let mut buffer = vec![0; MAX_CHUNK_SIZE as usize]; + + while read != buffer.len() { + let n = lock.stream.read(&mut buffer[read..]).await?; + if n == 0 { + if lock.current_part == self.total_parts - 1 { + break; + } else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "reached EOF before reaching the last file part", + )); + } + } + read += n; + } + + let bytes = if read == buffer.len() { + buffer + } else { + buffer[..read].to_vec() + }; + + let res = Ok(Some((lock.current_part, bytes))); + lock.current_part += 1; + res + } +} diff --git a/vendor/grammers-client/src/client/iter_buffer.rs b/vendor/grammers-client/src/client/iter_buffer.rs new file mode 100755 index 0000000..d729d71 --- /dev/null +++ b/vendor/grammers-client/src/client/iter_buffer.rs @@ -0,0 +1,95 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::collections::VecDeque; + +use grammers_mtsender::InvocationError; + +use super::Client; + +/// Common parts to most requests that are used for creating iterators. +/// +/// Client methods will either return a type alias or a wrapper around this. +pub struct IterBuffer { + pub(crate) client: Client, + pub(crate) limit: Option, + pub(crate) fetched: usize, + pub(crate) buffer: VecDeque, + pub(crate) last_chunk: bool, + pub(crate) total: Option, + pub(crate) request: R, +} + +impl IterBuffer { + /// Create a new `IterBuffer` instance from a handle, capacity and request. + pub(crate) fn from_request(client: &Client, capacity: usize, request: R) -> Self { + Self { + client: client.clone(), + limit: None, + fetched: 0, + buffer: VecDeque::with_capacity(capacity), + last_chunk: false, + total: None, + request, + } + } + + /// Change how many items will be returned from the iterator. + /// + /// Using `limit` instead of `take` on the iterator is useful because outgoing requests can + /// ask for less items from the server to only fetch what's needed. + pub fn limit(mut self, n: usize) -> Self { + self.limit = Some(n); + self + } + + /// Checks whether the limit has been reached and no more items should be fetched. + fn limit_reached(&self) -> bool { + if let Some(limit) = self.limit { + self.fetched >= limit + } else { + false + } + } + + /// Return the next result item from the buffer unless more data needs to be fetched. + /// + /// Data does not need to be fetched if the limit is reached or the buffer is empty and the + /// last chunk was reached. + pub(crate) fn next_raw(&mut self) -> Option, InvocationError>> { + if self.limit_reached() || (self.buffer.is_empty() && self.last_chunk) { + Some(Ok(None)) + } else { + self.pop_item().map(|item| Ok(Some(item))) + } + } + + /// Determines the new "limit" for the request, so that no unnecessary items are fetched from + /// the network. + pub(crate) fn determine_limit(&self, max: usize) -> i32 { + if let Some(limit) = self.limit { + if self.fetched < limit { + (limit - self.fetched).min(max) as i32 + } else { + 1 // 0 would cause Telegram to send a default amount and not actually 0 + } + } else { + max as i32 + } + } + + /// Pop a buffered item from the queue, and increment the amount of items fetched (returned). + pub(crate) fn pop_item(&mut self) -> Option { + if let Some(item) = self.buffer.pop_front() { + self.fetched += 1; + Some(item) + } else { + None + } + } +} diff --git a/vendor/grammers-client/src/client/messages.rs b/vendor/grammers-client/src/client/messages.rs new file mode 100644 index 0000000..2344a09 --- /dev/null +++ b/vendor/grammers-client/src/client/messages.rs @@ -0,0 +1,1327 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to sending messages. + +use std::collections::HashMap; + +use chrono::{DateTime, FixedOffset}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerAuth, PeerId, PeerKind, PeerRef}; +use grammers_tl_types::{self as tl, enums::InputPeer}; +use log::{Level, log_enabled, warn}; + +use super::{Client, IterBuffer}; +use crate::media::{InputMedia, Media}; +use crate::message::{InputMessage, InputReactions, Message}; +use crate::utils::{generate_random_id, generate_random_ids}; + +async fn map_random_ids_to_messages( + client: &Client, + fetched_in: PeerRef, + random_ids: &[i64], + updates: tl::enums::Updates, +) -> Result>, Box> { + match updates { + tl::enums::Updates::Updates(tl::types::Updates { + updates, + users, + chats, + date: _, + seq: _, + }) => { + let peers = client.build_peer_map(users, chats).await; + + let rnd_to_id = updates + .iter() + .filter_map(|update| match update { + tl::enums::Update::MessageId(u) => Some((u.random_id, u.id)), + _ => None, + }) + .collect::>(); + + // TODO ideally this would use the same UpdateIter mechanism to make sure we don't + // accidentally miss variants + let mut id_to_msg = updates + .into_iter() + .filter_map(|update| match update { + tl::enums::Update::NewMessage(tl::types::UpdateNewMessage { + message, .. + }) => Some(message), + tl::enums::Update::NewChannelMessage(tl::types::UpdateNewChannelMessage { + message, + .. + }) => Some(message), + tl::enums::Update::NewScheduledMessage( + tl::types::UpdateNewScheduledMessage { message, .. }, + ) => Some(message), + _ => None, + }) + .map(|message| { + Message::from_raw(client, message, Some(fetched_in.clone()), peers.handle()) + }) + .map(|message| (message.id(), message)) + .collect::>(); + + Ok(random_ids + .iter() + .map(|rnd| { + rnd_to_id + .get(rnd) + .and_then(|id| id_to_msg.remove(id)) + .or_else(|| { + if id_to_msg.len() == 1 { + // If there's no random_id to map from, in the common case a single message + // should've been produced regardless, so try to recover by returning that. + id_to_msg.drain().next().map(|(_, m)| m) + } else { + None + } + }) + }) + .collect()) + } + _ => panic!("API returned something other than Updates so messages can't be mapped"), + } +} + +pub(crate) async fn parse_mention_entities( + client: &Client, + mut entities: Vec, +) -> Option> { + if entities.is_empty() { + return None; + } + + if entities + .iter() + .any(|e| matches!(e, tl::enums::MessageEntity::MentionName(_))) + { + for entity in entities.iter_mut() { + if let tl::enums::MessageEntity::MentionName(mention_name) = entity { + *entity = tl::types::InputMessageEntityMentionName { + offset: mention_name.offset, + length: mention_name.length, + user_id: tl::enums::InputUser::User(tl::types::InputUser { + user_id: mention_name.user_id, + access_hash: client + .0 + .session + .peer(PeerId::user_unchecked(mention_name.user_id)) + .await + .unwrap_or_default() + .and_then(|peer| peer.auth()) + .unwrap_or_default() + .hash(), + }), + } + .into() + } + } + } + + Some(entities) +} + +const MAX_LIMIT: usize = 100; + +/// Build a reply-to a plain message by its ID, leaving all other fields unset. +fn input_reply_to(reply_to_msg_id: i32) -> tl::enums::InputReplyTo { + tl::types::InputReplyToMessage { + reply_to_msg_id, + top_msg_id: None, + 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() +} + +/// Flatten a `messages.Messages` response into its messages, users and chats. +/// +/// Panics on `NotModified`, which is only returned when a non-zero `hash` was sent. +fn unpack_messages( + res: tl::enums::messages::Messages, +) -> ( + Vec, + Vec, + Vec, +) { + use tl::enums::messages::Messages; + + match res { + Messages::Messages(m) => (m.messages, m.users, m.chats), + Messages::Slice(m) => (m.messages, m.users, m.chats), + Messages::ChannelMessages(m) => (m.messages, m.users, m.chats), + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though GetMessages was used") + } + } +} + +impl> IterBuffer { + /// Fetches the total unless cached. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn get_total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Messages; + + let total = match self.client.invoke(&self.request).await? { + Messages::Messages(messages) => messages.messages.len(), + Messages::Slice(messages) => messages.count as usize, + Messages::ChannelMessages(messages) => messages.count as usize, + Messages::NotModified(messages) => messages.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Performs the network call, fills the buffer, and returns the `offset_rate` if any. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn fill_buffer( + &mut self, + limit: i32, + reverse: bool, + peer: Option, + ) -> Result, InvocationError> { + use tl::enums::messages::Messages; + + let (mut messages, users, chats, rate) = match self.client.invoke(&self.request).await? { + Messages::Messages(m) => { + self.last_chunk = true; + self.total = Some(m.messages.len()); + (m.messages, m.users, m.chats, None) + } + Messages::Slice(m) => { + // Can't rely on `count(messages) < limit` as the stop condition. + // See https://t.me/tdlibchat/57257 for discussion. As an example: + // offset_id 132002, limit 4 => we get msg 131999 & 131998 + // offset_id 132002, limit 3 => we get msg 131999 + // offset_id 132002, limit 2 => we get msg 132000 + // offset_id 132002, limit 1 => we get msg 132001 + // + // When iterating newest-to-oldest, the highest fetched message ID is + // lower than or equal to the limit, there can't be more messages after + // (highest ID - limit), because the absolute lowest message ID is 1. + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + + self.total = Some(m.count as usize); + + (m.messages, m.users, m.chats, m.next_rate) + } + Messages::ChannelMessages(m) => { + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + self.total = Some(m.count as usize); + (m.messages, m.users, m.chats, None) + } + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though hash = 0") + } + }; + + if reverse { + messages.reverse(); + } + + let peers = self.client.build_peer_map(users, chats).await; + + let client = self.client.clone(); + self.buffer.extend( + messages + .into_iter() + .map(|message| Message::from_raw(&client, message, peer, peers.handle())), + ); + + Ok(rate) + } +} + +/// Iterator returned by [`Client::iter_messages`]. +pub struct MessageIter { + inner: IterBuffer, + reverse: bool, +} + +impl MessageIter { + fn new(client: &Client, peer: PeerRef) -> Self { + let inner = IterBuffer::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetHistory { + peer: peer.into(), + offset_id: 0, + offset_date: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ); + + Self { + inner, + reverse: false, + } + } + + /// Changes the starting message ID (exclusive). + pub fn offset_id(mut self, offset: i32) -> Self { + self.inner.request.offset_id = offset; + self + } + + /// Changes the starting message date. + pub fn offset_date(mut self, offset: i32) -> Self { + self.inner.request.offset_date = offset; + self + } + + /// Changes the order to oldest-to-newest. (default is newest-to-oldest) + pub fn reverse(mut self, reverse: bool) -> Self { + self.reverse = reverse; + self + } + + pub fn limit(mut self, limit: usize) -> Self { + self.inner = self.inner.limit(limit); + self + } + + /// Determines the total number of messages in the chat. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.inner.request.limit = 1; + self.inner.get_total().await + } + + /// Returns the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.inner.next_raw() { + return result; + } + + self.inner.request.limit = self.inner.determine_limit(MAX_LIMIT); + let request = &mut self.inner.request; + if self.reverse { + request.add_offset = -request.limit; + request.min_id = request.offset_id; + if request.offset_id == 0 && request.offset_date == 0 { + // With no explicit offset, start from the oldest available page. + request.offset_id = 1; + request.add_offset = -request.limit + 1; // +1 to include the first message (ID=1) + } + } + self.inner + .fill_buffer( + self.inner.request.limit, + self.reverse, + Some(self.inner.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.inner.last_chunk && !self.inner.buffer.is_empty() { + let last = &self.inner.buffer[self.inner.buffer.len() - 1]; + self.inner.request.offset_id = last.id(); + self.inner.request.offset_date = last.date_timestamp(); + } + + Ok(self.inner.pop_item()) + } +} + +/// Iterator returned by [`Client::search_messages`]. +pub type SearchIter = IterBuffer; + +impl SearchIter { + fn new(client: &Client, peer: PeerRef) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::Search { + peer: peer.into(), + q: String::new(), + from_id: None, + saved_peer_id: None, + saved_reaction: None, + top_msg_id: None, + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_id: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Restricts results to messages sent by the logged-in user + pub fn sent_by_self(mut self) -> Self { + self.request.from_id = Some(InputPeer::PeerSelf); + self + } + + /// Returns only messages with date bigger than date_time. + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent after Jan 1st, 2021 + /// let min_date = DateTime::parse_from_rfc3339("2021-01-01T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).min_date(&min_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn min_date(mut self, date_time: &DateTime) -> Self { + self.request.min_date = date_time.timestamp() as i32; + self + } + + /// Returns only messages with date smaller than date_time + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent before Dec, 25th 2022 + /// let max_date = DateTime::parse_from_rfc3339("2022-12-25T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).max_date(&max_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn max_date(mut self, date_time: &DateTime) -> Self { + self.request.max_date = date_time.timestamp() as i32; + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + // Unlike most requests, a limit of 0 actually returns 0 and not a default amount + // (as of layer 120). + self.request.limit = 0; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + self.fill_buffer( + self.request.limit, + false, + Some(self.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_id = last.id(); + self.request.max_date = last.date_timestamp(); + } + + Ok(self.pop_item()) + } +} + +/// Iterator returned by [`Client::search_all_messages`]. +pub type GlobalSearchIter = IterBuffer; + +impl GlobalSearchIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::SearchGlobal { + folder_id: None, + q: String::new(), + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_rate: 0, + offset_peer: tl::enums::InputPeer::Empty, + offset_id: 0, + limit: 0, + broadcasts_only: false, + groups_only: false, + users_only: false, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.request.limit = 1; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + let offset_rate = self.fill_buffer(self.request.limit, false, None).await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_rate = offset_rate.unwrap_or(0); + self.request.offset_peer = last + .peer_ref() + .await? + .map(|peer| peer.into()) + .unwrap_or(tl::enums::InputPeer::Empty); + self.request.offset_id = last.id(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to sending, modifying or getting messages. +impl Client { + /// Sends a message to the desired peer. + /// + /// This method can also be used to send media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMessage`] to learn more formatting options, such as using markdown or + /// adding buttons under your message (if you're logged in as a bot). + /// + /// See also: [`Message::respond`], [`Message::reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.send_message(peer, "Boring text message :-(").await?; + /// + /// use grammers_client::message::InputMessage; + /// + /// client.send_message(peer, InputMessage::new().text("Sneaky message").silent(true)).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_message, M: Into>( + &self, + peer: C, + message: M, + ) -> Result { + let peer = peer.into(); + let message = message.into(); + let random_id = generate_random_id(); + let entities = parse_mention_entities(self, message.entities.clone()).await; + let updates = if let Some(media) = message.media.clone() { + self.invoke(&tl::functions::messages::SendMedia { + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + media, + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + }) + .await + } else { + self.invoke(&tl::functions::messages::SendMessage { + no_webpage: !message.link_preview, + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + rich_message: None, + }) + .await + }?; + + Ok(match updates { + tl::enums::Updates::UpdateShortSentMessage(updates) => { + let peer = if peer.id.bare_id().is_none() { + // from_raw_short_updates needs the peer ID + self.0.session.peer_ref(peer.id).await?.unwrap() + } else { + peer + }; + + Message::from_raw_short_updates(self, updates, message, peer) + } + updates => { + let updates_debug = if log_enabled!(Level::Warn) { + Some(updates.clone()) + } else { + None + }; + + match map_random_ids_to_messages(self, peer, &[random_id], updates) + .await? + .pop() + .flatten() + { + Some(message) => message, + None => { + if let Some(updates) = updates_debug { + warn!( + "failed to find just-sent message in response updates; please report this:" + ); + warn!("{:#?}", updates); + } + Message::from_raw( + self, + tl::enums::Message::Empty(tl::types::MessageEmpty { + id: 0, + peer_id: Some(peer.id.into()), + }), + Some(peer), + self.empty_peer_map(), + ) + } + } + } + }) + } + + /// Sends a album to the desired peer. + /// + /// This method can also be used to send a bunch of media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMedia`] to learn more formatting options, such as using markdown. + /// + /// See also: [`Message::respond_album`], [`Message::reply_album`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// client.send_album(peer, vec![InputMedia::new().caption("A album").photo_url("https://example.com/cat.jpg")]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_album>( + &self, + peer: C, + mut medias: Vec, + ) -> Result>, InvocationError> { + let peer = peer.into(); + let random_ids = generate_random_ids(medias.len()); + + // Upload external files + for media in medias.iter_mut() { + let raw_media = media.media.clone().unwrap(); + + if matches!( + raw_media, + tl::enums::InputMedia::UploadedPhoto(_) + | tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::UploadedDocument(_) + | tl::enums::InputMedia::DocumentExternal(_) + ) { + let uploaded = self + .invoke(&tl::functions::messages::UploadMedia { + business_connection_id: None, + peer: peer.into(), + media: raw_media, + }) + .await?; + media.media = Some( + Media::from_raw(uploaded) + .unwrap() + .to_raw_input_media() + .unwrap(), + ); + } + } + + let first_media_reply = medias.first().unwrap().reply_to; + + let mut multi_media = Vec::with_capacity(medias.len()); + for (input_media, random_id) in medias.into_iter().zip(random_ids.iter()) { + let entities = parse_mention_entities(self, input_media.entities).await; + let raw_media = input_media.media.unwrap(); + multi_media.push(tl::enums::InputSingleMedia::Media( + tl::types::InputSingleMedia { + media: raw_media, + random_id: *random_id, + message: input_media.caption, + entities, + }, + )) + } + + let updates = self + .invoke(&tl::functions::messages::SendMultiMedia { + silent: false, + background: false, + clear_draft: false, + peer: peer.into(), + reply_to: first_media_reply.map(input_reply_to), + schedule_date: None, + multi_media, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: false, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + }) + .await?; + + Ok(map_random_ids_to_messages(self, peer, &random_ids, updates).await?) + } + + /// Edits an existing message. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + /// + /// See also: [`Message::edit`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let old_message_id = 123; + /// client.edit_message(peer, old_message_id, "New text message").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn edit_message, M: Into>( + &self, + peer: C, + message_id: i32, + new_message: M, + ) -> Result<(), InvocationError> { + let new_message = new_message.into(); + let entities = parse_mention_entities(self, new_message.entities).await; + self.invoke(&tl::functions::messages::EditMessage { + no_webpage: !new_message.link_preview, + invert_media: new_message.invert_media, + peer: peer.into().into(), + id: message_id, + message: Some(new_message.text), + media: new_message.media, + reply_markup: new_message.reply_markup, + entities, + schedule_date: new_message.schedule_date, + schedule_repeat_period: None, + quick_reply_shortcut_id: None, + rich_message: None, + }) + .await?; + + Ok(()) + } + + /// Deletes up to 100 messages in a peer. + /// + ///
+ /// + /// When deleting messages from small group peers or private conversations, this + /// method cannot validate that the provided message IDs actually belong to the input peer due + /// to the way Telegram's API works. Make sure to pass correct [`Message::id`]'s. + /// + ///
+ /// + /// The messages are deleted for both ends. + /// + /// The amount of deleted messages is returned (it might be less than the amount of input + /// message IDs if some of them were already missing). It is not possible to find out which + /// messages were actually deleted, but if the request succeeds, none of the specified message + /// IDs will appear in the message history from that point on. + /// + /// See also: [`Message::delete`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// // Careful, these messages will be gone after the method succeeds! + /// client.delete_messages(peer, &message_ids).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_messages>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result { + let peer = peer.into(); + let tl::enums::messages::AffectedMessages::Messages(affected) = + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::DeleteMessages { + channel: peer.into(), + id: message_ids.to_vec(), + }) + .await + } else { + self.invoke(&tl::functions::messages::DeleteMessages { + revoke: true, + id: message_ids.to_vec(), + }) + .await + }?; + + Ok(affected.pts_count as usize) + } + + /// Forwards up to 100 messages from `source` into `destination`. + /// + /// For consistency with other methods, the peer upon which this request acts comes first + /// (destination), and then the source peer. + /// + /// Returns the new forwarded messages in a list. Those messages that could not be forwarded + /// will be `None`. The length of the resulting list is the same as the length of the input + /// message IDs, and the indices from the list of IDs map to the indices in the result so + /// you can find which messages were forwarded and which message they became. + /// + /// See also: [`Message::forward_to`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(destination: grammers_session::types::PeerRef, source: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.forward_messages(destination, &message_ids, source).await?; + /// let fwd_count = messages.into_iter().filter(Option::is_some).count(); + /// println!("Forwarded {} out of {} messages!", fwd_count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn forward_messages, S: Into>( + &self, + destination: C, + message_ids: &[i32], + source: S, + ) -> Result>, InvocationError> { + // TODO let user customize more options + let peer = destination.into(); + let request = tl::functions::messages::ForwardMessages { + silent: false, + background: false, + with_my_score: false, + drop_author: false, + drop_media_captions: false, + from_peer: source.into().into(), + id: message_ids.to_vec(), + random_id: generate_random_ids(message_ids.len()), + to_peer: peer.into(), + top_msg_id: None, + reply_to: None, + schedule_date: None, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + quick_reply_shortcut: None, + allow_paid_floodskip: false, + effect: None, + video_timestamp: None, + allow_paid_stars: None, + suggested_post: None, + }; + let result = self.invoke(&request).await?; + Ok(map_random_ids_to_messages(self, peer.into(), &request.random_id, result).await?) + } + + /// Gets the [`Message`] to which the input message is replying to. + /// + /// See also: [`Message::get_reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(reply) = client.get_reply_to_message(&message).await? { + /// println!("The reply said: {}", reply.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_reply_to_message( + &self, + message: &Message, + ) -> Result, InvocationError> { + /// Helper method to fetch a single message by its input message. + async fn get_message( + client: &Client, + peer: PeerRef, + id: tl::enums::InputMessage, + ) -> Result<(tl::enums::messages::Messages, bool), InvocationError> { + if peer.id.kind() == PeerKind::Channel { + client + .invoke(&tl::functions::channels::GetMessages { + id: vec![id], + channel: peer.into(), + }) + .await + .map(|res| (res, false)) + } else { + client + .invoke(&tl::functions::messages::GetMessages { id: vec![id] }) + .await + .map(|res| (res, true)) + } + } + + // TODO shouldn't this method take in a message id anyway? + let peer_id = message.peer_id(); + let peer = match peer_id.kind() { + PeerKind::User | PeerKind::Chat => PeerRef { + id: peer_id, + auth: PeerAuth::default(), // unused, so no need to bother fetching it + }, + PeerKind::Channel => message.peer_ref().await?.ok_or(InvocationError::Dropped)?, + }; + let reply_to_message_id = match message.reply_to_message_id() { + Some(id) => id, + None => return Ok(None), + }; + + let input_id = + tl::enums::InputMessage::ReplyTo(tl::types::InputMessageReplyTo { id: message.id() }); + + let (res, filter_req) = match get_message(self, peer, input_id).await { + Ok(tup) => tup, + Err(_) => { + let input_id = tl::enums::InputMessage::Id(tl::types::InputMessageId { + id: reply_to_message_id, + }); + get_message(self, peer, input_id).await? + } + }; + + let (messages, users, chats) = unpack_messages(res); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .next() + .filter(|m| !filter_req || m.peer_id() == message.peer_id())) + } + + /// Iterate over the message history of a peer, from most recent to oldest. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Note we're setting a reasonable limit, or we'd print out ALL the messages in peer! + /// let mut messages = client.iter_messages(peer).limit(100); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_messages>(&self, peer: C) -> MessageIter { + MessageIter::new(self, peer.into()) + } + + /// Iterate over the messages that match certain search criteria. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the people who think grammers is cool. + /// let mut messages = client.search_messages(peer).query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// let sender = message.sender().unwrap(); + /// println!("{}", sender.name().unwrap_or(&sender.id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_messages>(&self, peer: C) -> SearchIter { + SearchIter::new(self, peer.into()) + } + + /// Iterate over the messages that match certain search criteria, without being restricted to + /// searching in a specific peer. The downside is that this global search supports less filters. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the peers were people think grammers is cool. + /// let mut messages = client.search_all_messages().query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.peer().unwrap().name().unwrap_or(&message.peer().unwrap().id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_all_messages(&self) -> GlobalSearchIter { + GlobalSearchIter::new(self) + } + + /// Get up to 100 messages using their ID. + /// + /// Returns the new retrieved messages in a list. Those messages that could not be retrieved + /// or do not belong to the input peer will be `None`. The length of the resulting list is the + /// same as the length of the input message IDs, and the indices from the list of IDs map to + /// the indices in the result so you can map them into the new list. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.get_messages_by_id(peer, &message_ids).await?; + /// let count = messages.into_iter().filter(Option::is_some).count(); + /// println!("{} out of {} messages were deleted!", message_ids.len() - count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_messages_by_id>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result>, InvocationError> { + let peer = peer.into(); + let id = message_ids + .iter() + .map(|&id| tl::enums::InputMessage::Id(tl::types::InputMessageId { id })) + .collect(); + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + let mut map = messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .filter(|m| m.peer_id() == peer.id) + .map(|m| (m.id(), m)) + .collect::>(); + + Ok(message_ids.iter().map(|id| map.remove(id)).collect()) + } + + /// Get the latest pin from a peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(message) = client.get_pinned_message(peer).await? { + /// println!("There is a message pinned: {}", message.text()); + /// } else { + /// println!("There are no messages pinned"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_pinned_message>( + &self, + peer: C, + ) -> Result, InvocationError> { + let peer = peer.into(); + // TODO return types::Message and print its text in the example + let id = vec![tl::enums::InputMessage::Pinned]; + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .find(|m| m.peer_id() == peer.id)) + } + + /// Pin a message in the peer. This will not notify any users. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.pin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + // TODO return produced Option + pub async fn pin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, true).await + } + + /// Unpin a message from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.unpin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, false).await + } + + async fn update_pinned( + &self, + peer: PeerRef, + id: i32, + pin: bool, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UpdatePinnedMessage { + silent: true, + unpin: !pin, + pm_oneside: false, + peer: peer.into(), + id, + }) + .await + .map(drop) + } + + /// Unpin all currently-pinned messages from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.unpin_all_messages(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_all_messages>( + &self, + peer: C, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UnpinAllMessages { + peer: peer.into().into(), + top_msg_id: None, + saved_peer_id: None, + }) + .await?; + Ok(()) + } + + /// Send reaction. + /// + /// # Examples + /// + /// Via emoticon + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, "👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// client.send_reactions(peer, message_id, reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_reactions, R: Into>( + &self, + peer: C, + message_id: i32, + reactions: R, + ) -> Result<(), InvocationError> { + let reactions = reactions.into(); + + self.invoke(&tl::functions::messages::SendReaction { + big: reactions.big, + add_to_recent: reactions.add_to_recent, + peer: peer.into().into(), + msg_id: message_id, + reaction: Some(reactions.reactions), + }) + .await?; + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/mod.rs b/vendor/grammers-client/src/client/mod.rs new file mode 100755 index 0000000..0df6942 --- /dev/null +++ b/vendor/grammers-client/src/client/mod.rs @@ -0,0 +1,34 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains client-specific configuration and types. + +mod auth; +mod bots; +mod chats; +#[allow(clippy::module_inception)] +mod client; +mod dialogs; +mod files; +mod iter_buffer; +mod messages; +mod net; +mod retry_policy; +mod updates; + +pub use auth::{LoginToken, PasswordToken, SignInError}; +pub use bots::{InlineResult, InlineResultIter}; +pub use chats::{ParticipantIter, ParticipantPermissions, ProfilePhotoIter}; +pub(crate) use client::ClientInner; +pub use client::{Client, ClientConfiguration, UpdatesConfiguration}; +pub use dialogs::DialogIter; +pub use files::DownloadIter; +pub use iter_buffer::IterBuffer; +pub use messages::{GlobalSearchIter, MessageIter, SearchIter}; +pub use retry_policy::{AutoSleep, NoRetries, RetryContext, RetryPolicy}; +pub use updates::UpdateStream; diff --git a/vendor/grammers-client/src/client/net.rs b/vendor/grammers-client/src/client/net.rs new file mode 100644 index 0000000..745ff79 --- /dev/null +++ b/vendor/grammers-client/src/client/net.rs @@ -0,0 +1,198 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, SenderPoolFatHandle}; +use grammers_tl_types::{self as tl, Deserializable}; +use log::info; +use tokio::{sync::Mutex, time::sleep}; + +use super::{Client, ClientConfiguration, ClientInner, RetryContext}; + +/// Method implementations directly related with network connectivity. +impl Client { + /// Creates and returns a new client instance upon successful connection to Telegram. + /// + /// If the session in the configuration did not have an authorization key, a new one + /// will be created and the session will be saved with it. + /// + /// The connection will be initialized with the data from the input configuration. + /// + /// The [`grammers_mtsender::SenderPoolHandle`] does not keep a reference to the [`grammers_session::Session`] + /// or `api_id`, but the [`SenderPool`] itself does, so the latter is used as input to guarantee + /// that the values are correctly shared between the pool and the client handles. + /// + /// # Examples + /// + /// ``` + /// use std::sync::Arc; + /// use grammers_client::Client; + /// use grammers_session::storages::MemorySession; // avoid this storage outside tests! + /// use grammers_mtsender::SenderPool; + /// + /// // Note: these are example values and are not actually valid. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_ID: i32 = 932939; + /// + /// # async fn f() -> Result<(), Box> { + /// let session = Arc::new(MemorySession::default()); + /// let pool = SenderPool::new(Arc::clone(&session), API_ID); + /// let client = Client::new(pool.handle); + /// # Ok(()) + /// # } + /// ``` + pub fn new(sender_pool: SenderPoolFatHandle) -> Self { + Self::with_configuration(sender_pool, Default::default()) + } + + /// Like [`Self::new`] but with a custom [`ClientConfiguration`]. + pub fn with_configuration( + sender_pool: SenderPoolFatHandle, + configuration: ClientConfiguration, + ) -> Self { + // TODO Sender doesn't have a way to handle backpressure yet + Self(Arc::new(ClientInner { + session: sender_pool.session, + api_id: sender_pool.api_id, + handle: sender_pool.thin, + configuration, + auth_copied_to_dcs: Mutex::new(Vec::new()), + })) + } + + /// Invoke a raw API call. This directly sends the request to Telegram's servers. + /// + /// Using function definitions corresponding to a different layer is likely to cause the + /// responses to the request to not be understood. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn invoke( + &self, + request: &R, + ) -> Result { + let dc_id = self.0.session.home_dc_id()?; + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + /// Like [`Self::invoke`], but in the specified DC. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ pub async fn invoke_in_dc( + &self, + dc_id: i32, + request: &R, + ) -> Result { + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + async fn do_invoke_in_dc( + &self, + dc_id: i32, + request_body: Vec, + ) -> Result, InvocationError> { + let mut retry_context = RetryContext { + fail_count: NonZeroU32::new(1).unwrap(), + slept_so_far: Duration::default(), + error: InvocationError::Dropped, + }; + + loop { + match self + .0 + .handle + .invoke_in_dc(dc_id, request_body.clone()) + .await + { + Ok(response) => break Ok(response), + Err(e) => { + retry_context.error = e; + match self + .0 + .configuration + .retry_policy + .should_retry(&retry_context) + { + ControlFlow::Continue(delay) => { + info!( + "sleeping on {} for {:?} before retrying", + retry_context.error, delay, + ); + sleep(delay).await; + retry_context.fail_count = retry_context.fail_count.saturating_add(1); + retry_context.slept_so_far += delay; + continue; + } + ControlFlow::Break(()) => break Err(retry_context.error), + } + } + } + } + } + + pub(crate) async fn copy_auth_to_dc(&self, target_dc_id: i32) -> Result<(), InvocationError> { + let mut auth_copied_to_dcs = self.0.auth_copied_to_dcs.lock().await; + if auth_copied_to_dcs.contains(&target_dc_id) { + return Ok(()); + } + + let home_dc_id = self.0.session.home_dc_id()?; + if target_dc_id == home_dc_id { + return Ok(()); + } + + let tl::enums::auth::ExportedAuthorization::Authorization(exported_auth) = self + .invoke(&tl::functions::auth::ExportAuthorization { + dc_id: target_dc_id, + }) + .await?; + + self.invoke_in_dc( + target_dc_id, + &tl::functions::auth::ImportAuthorization { + id: exported_auth.id, + bytes: exported_auth.bytes, + }, + ) + .await?; + + auth_copied_to_dcs.push(target_dc_id); + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/retry_policy.rs b/vendor/grammers-client/src/client/retry_policy.rs new file mode 100755 index 0000000..2f5e5aa --- /dev/null +++ b/vendor/grammers-client/src/client/retry_policy.rs @@ -0,0 +1,94 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Retry policy trait and built-in policies for use in [`ClientConfiguration`]. +//! +//! [`ClientConfiguration`]: crate::ClientConfiguration + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; + +/// This trait controls how the [`Client`] should behave when +/// an invoked request fails with an [`InvocationError`]. +/// +/// [`Client`]: crate::Client +pub trait RetryPolicy: Send + Sync { + /// Determines whether the failing request should retry. + /// + /// If it should Continue, a sleep duration before retrying is included.\ + /// If it should Break, the context error will be propagated to the caller. + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration>; +} + +/// Context passed to [`RetryPolicy::should_retry`]. +pub struct RetryContext { + /// Amount of times the instance of this request has failed. + pub fail_count: NonZeroU32, + /// Sum of the durations for all previous continuations (not total time elapsed since first failure). + pub slept_so_far: Duration, + /// The most recent error caused by the instance of the request. + pub error: InvocationError, +} + +/// Retry policy that will never retry. +pub struct NoRetries; + +impl RetryPolicy for NoRetries { + fn should_retry(&self, _: &RetryContext) -> ControlFlow<(), Duration> { + ControlFlow::Break(()) + } +} + +/// Retry policy that will retry *once* on flood-wait and slow mode wait errors. +/// +/// The library will sleep only if the duration to sleep for is below or equal to the threshold. +pub struct AutoSleep { + /// The (inclusive) threshold below which the library should automatically sleep. + pub threshold: Duration, + + /// `Some` if I/O errors should be treated as a flood error that would last the specified duration. + /// This duration will ignore the `threshold` and always be slept on on the first I/O error. + pub io_errors_as_flood_of: Option, +} + +impl RetryPolicy for AutoSleep { + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration> { + match ctx.error { + InvocationError::Rpc(RpcError { + code: 420, + value: Some(seconds), + .. + }) if ctx.fail_count.get() == 1 && seconds as u64 <= self.threshold.as_secs() => { + ControlFlow::Continue(Duration::from_secs(seconds as _)) + } + InvocationError::Io(_) if ctx.fail_count.get() == 1 => { + if let Some(duration) = self.io_errors_as_flood_of { + ControlFlow::Continue(duration) + } else { + ControlFlow::Break(()) + } + } + _ => ControlFlow::Break(()), + } + } +} + +impl Default for AutoSleep { + /// Returns an instance with a threshold of 60 seconds. + /// + /// I/O errors will be treated as if they were a 1-second flood. + fn default() -> Self { + Self { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + } + } +} diff --git a/vendor/grammers-client/src/client/updates.rs b/vendor/grammers-client/src/client/updates.rs new file mode 100644 index 0000000..ed79919 --- /dev/null +++ b/vendor/grammers-client/src/client/updates.rs @@ -0,0 +1,419 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods to deal with and offer access to updates. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use grammers_mtsender::InvocationError; +use grammers_session::ErasedSession; +use grammers_session::types::{PeerId, PeerInfo, UpdateState, UpdatesState}; +use grammers_session::updates::{MessageBoxes, PrematureEndReason, State, UpdatesLike}; +use grammers_tl_types as tl; +use log::{trace, warn}; +use tokio::sync::{mpsc, watch}; +use tokio::time::timeout_at; + +use super::{Client, UpdatesConfiguration}; +use crate::peer::PeerMap; +use crate::update::Update; + +/// How long to wait after warning the user that the updates limit was exceeded. +const UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN: Duration = Duration::from_secs(300); + +// See https://core.telegram.org/method/updates.getChannelDifference. +const BOT_CHANNEL_DIFF_LIMIT: i32 = 100000; +const USER_CHANNEL_DIFF_LIMIT: i32 = 100; + +async fn prepare_channel_difference( + mut request: tl::functions::updates::GetChannelDifference, + session: &ErasedSession, + message_box: &mut MessageBoxes, +) -> Result< + Option, + Box, +> { + let id = match &request.channel { + tl::enums::InputChannel::Channel(channel) => PeerId::channel_unchecked(channel.channel_id), + _ => unreachable!(), + }; + + if let Some(PeerInfo::Channel { + id, + auth: Some(auth), + .. + }) = session.peer(id).await? + { + request.channel = tl::enums::InputChannel::Channel(tl::types::InputChannel { + channel_id: id, + access_hash: auth.hash(), + }); + request.limit = if session + .peer(PeerId::self_user()) + .await? + .map(|user| match user { + PeerInfo::User { bot, .. } => bot.unwrap_or(false), + _ => false, + }) + .unwrap_or(false) + { + BOT_CHANNEL_DIFF_LIMIT + } else { + USER_CHANNEL_DIFF_LIMIT + }; + trace!("requesting {:?}", request); + Ok(Some(request)) + } else { + warn!( + "cannot getChannelDifference for {:?} as we're missing its hash", + id + ); + message_box.end_channel_difference(PrematureEndReason::Banned); + Ok(None) + } +} + +/// Iterator returned by [`Client::stream_updates`]. +pub struct UpdateStream { + client: Client, + message_box: MessageBoxes, + // When did we last warn the user that the update queue filled up? + // This is used to avoid spamming the log. + last_update_limit_warn: Option, + buffer: VecDeque<(tl::enums::Update, State, PeerMap)>, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + should_get_state: bool, + active_channel: watch::Sender>, + active_changes: watch::Receiver>, +} + +impl UpdateStream { + /// Latest viewed channel and its initial PTS. None stops active short polling. + /// Changing this handle does not cancel an in-flight update receive. + pub fn active_channel(&self) -> watch::Sender> { + self.active_channel.clone() + } + + /// Return one complete application batch and the cursor it covers. + /// Persist every update before committing this cursor. In particular, a + /// difference response can give several updates the same terminal PTS. + pub async fn next_batch(&mut self) -> Result<(Vec, UpdatesState), InvocationError> { + let first = self.next_raw().await?; + let raw = std::iter::once(first).chain(self.buffer.drain(..)); + let updates = raw.map(|(update, state, peers)| { + Update::from_raw(&self.client, update, state, peers) + }).collect(); + Ok((updates, self.message_box.session_state())) + } + + /// Restore the cursor owned by the application's durable message store. + /// Call before polling this stream. Session peer/dialog cursors may be ahead + /// of the messages persisted by an application and are not a checkpoint. + pub fn restore_state(&mut self, state: UpdatesState) { + assert!(self.buffer.is_empty(), "restore state before polling updates"); + self.message_box = MessageBoxes::load(state); + self.should_get_state = self.message_box.is_empty(); + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + pub async fn next(&mut self) -> Result { + let (update, state, peers) = self.next_raw().await?; + Ok(Update::from_raw(&self.client, update, state, peers)) + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + /// + /// Unlike [`Self::next`], the update is not wrapped at all, but it is still processed. + pub async fn next_raw( + &mut self, + ) -> Result<(tl::enums::Update, State, PeerMap), InvocationError> { + if self.should_get_state { + self.should_get_state = false; + match self + .client + .invoke(&tl::functions::updates::GetState {}) + .await + { + Ok(tl::enums::updates::State::State(state)) => { + self.client + .0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + self.message_box.set_state(state); + // A new application cache needs this initial checkpoint + // even if no live message arrives during the session. + // Signal that older content has no replayable coverage. + let state = self.message_box.session_state(); + let peers = self.client.build_peer_map(Vec::new(), Vec::new()).await; + self.buffer.push_back((tl::enums::Update::PtsChanged, State { + date: state.date, seq: state.seq, message_box: None, + }, peers)); + } + Err(_err) => { + // The account may no longer actually be logged in, or it can rarely fail. + // `message_box` will try to correct its state as updates arrive. + } + } + } + + loop { + let (deadline, get_diff, get_channel_diff) = { + if let Some(update) = self.buffer.pop_front() { + return Ok(update); + } + ( + self.message_box.check_deadlines(), // first, as it might trigger differences + self.message_box.get_difference(), + match self.message_box.get_channel_difference() { + Some(gd) => { + prepare_channel_difference( + gd, + self.client.0.session.as_ref(), + &mut self.message_box, + ) + .await? + } + None => None, + }, + ) + }; + + if let Some(request) = get_diff { + let response = self.client.invoke(&request).await?; + let invalidated = matches!(&response, tl::enums::updates::Difference::TooLong(_)); + let (mut updates, users, chats) = self.message_box.apply_difference(response); + if invalidated { + let state = self.message_box.session_state(); + updates.insert(0, (tl::enums::Update::PtsChanged, State { + date: state.date, seq: state.seq, message_box: None, + })); + } + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + if let Some(request) = get_channel_diff { + let maybe_response = self.client.invoke(&request).await; + + let response = match maybe_response { + Ok(r) => r, + Err(e) if e.is("PERSISTENT_TIMESTAMP_OUTDATED") => { + // According to Telegram's docs: + // "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)." + // We can treat this as "empty difference" and not update the local pts. + // Then this same call will be retried when another gap is detected or timeout expires. + // + // Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few + // seconds, but if Telegram is having issues it's probably best to wait for it to send another + // update (hinting it may be okay now) and retry then. + // + // This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts. + // Instead we manually extract the previously-known pts and use that. + log::warn!( + "Getting difference for channel updates caused PersistentTimestampOutdated; ending getting difference prematurely until server issues are resolved" + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) if e.is("CHANNEL_PRIVATE") => { + log::info!( + "Account is now banned so we can no longer fetch updates with request: {:?}", + request + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::Banned); + } + continue; + } + Err(InvocationError::Rpc(rpc_error)) if rpc_error.code == 500 => { + log::warn!("Telegram is having internal issues: {:#?}", rpc_error); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) => return Err(e), + }; + + let invalidated_channel = if matches!(&response, tl::enums::updates::ChannelDifference::TooLong(_)) { + match &request.channel { + tl::enums::InputChannel::Channel(channel) => Some(channel.channel_id), + _ => None, + } + } else { None }; + let (mut updates, users, chats) = self.message_box.apply_channel_difference(response); + if let Some(channel_id) = invalidated_channel { + let state = self.message_box.session_state(); + updates.insert(0, (tl::types::UpdateChannelTooLong { channel_id, pts: None }.into(), State { + date: state.date, seq: state.seq, message_box: None, + })); + } + + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + tokio::select! { + _ = self.active_changes.changed() => { + let active = *self.active_changes.borrow_and_update(); + self.message_box.set_active_channel(active); + } + result = timeout_at(deadline.into(), self.updates.recv()) => match result { + Ok(Some(updates)) => self.process_socket_updates(updates).await?, + Ok(None) => break Err(InvocationError::Dropped), + Err(_) => {} + } + } + } + } + + pub(crate) async fn process_socket_updates( + &mut self, + updates: UpdatesLike, + ) -> Result<(), InvocationError> { + let mut result = Option::<(Vec<_>, Vec<_>, Vec<_>)>::None; + match self.message_box.process_updates(updates) { + Ok(tup) => { + if let Some(res) = result.as_mut() { + res.0.extend(tup.0); + res.1.extend(tup.1); + res.2.extend(tup.2); + } else { + result = Some(tup); + } + } + Err(_) => return Ok(()), + } + + if let Some((updates, users, chats)) = result { + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + } + + Ok(()) + } + + fn extend_update_queue( + &mut self, + mut updates: Vec<(tl::enums::Update, State)>, + peer_map: PeerMap, + ) { + if let Some(limit) = self.configuration.update_queue_limit { + if let Some(exceeds) = (self.buffer.len() + updates.len()).checked_sub(limit + 1) { + let exceeds = exceeds + 1; + let now = Instant::now(); + let notify = match self.last_update_limit_warn { + None => true, + Some(instant) => now - instant > UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN, + }; + + updates.truncate(updates.len() - exceeds); + if notify { + log::warn!( + "{} updates were dropped because the update_queue_limit was exceeded", + exceeds + ); + } + + self.last_update_limit_warn = Some(now); + } + } + + self.buffer + .extend(updates.into_iter().map(|(u, s)| (u, s, peer_map.handle()))); + } + + /// Synchronize the updates state to the session. + /// + /// This is **not** automatically done on drop. + pub async fn sync_update_state(&self) -> Result<(), Box> { + self.client + .0 + .session + .set_update_state(UpdateState::All(self.message_box.session_state())) + .await?; + Ok(()) + } +} + +impl Client { + /// Returns an asynchronous stream of processed updates. + /// + /// The updates are guaranteed to be in order, and any gaps will be resolved.\ + /// **Important** to note that for gaps to be resolved, the peers must have been + /// persisted in the session cache beforehand (i.e. be retrievable with [`Session::peer`]). + /// A good way to achieve this is to use [`Self::iter_dialogs`] at least once after login. + /// + /// The updates are wrapped in [`crate::update::Update`] to make them more convenient to use, + /// but their raw type is still accessible to bridge any missing functionality. + pub async fn stream_updates( + &self, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + ) -> Result> { + let message_box = if configuration.catch_up { + MessageBoxes::load(self.0.session.updates_state().await?) + } else { + // If the user doesn't want to bother with catching up on previous update, start with + // pristine state instead. + MessageBoxes::new() + }; + // Don't bother getting pristine update state if we're not logged in. + let should_get_state = + message_box.is_empty() && self.0.session.peer(PeerId::self_user()).await?.is_some(); + + let (active_channel, active_changes) = watch::channel(None); + Ok(UpdateStream { + active_channel, + active_changes, + client: self.clone(), + message_box, + last_update_limit_warn: None, + buffer: VecDeque::new(), + updates, + configuration, + should_get_state, + }) + } +} + +#[cfg(test)] +mod tests { + use core::future::Future; + + use super::*; + + fn get_update_stream() -> UpdateStream { + panic!() + } + + #[test] + fn ensure_next_update_future_impls_send() { + if false { + // We just want it to type-check, not actually run. + fn typeck(_: impl Future + Send) {} + typeck(get_update_stream().next()); + } + } +} diff --git a/vendor/grammers-client/src/lib.rs b/vendor/grammers-client/src/lib.rs new file mode 100755 index 0000000..d9a6bc6 --- /dev/null +++ b/vendor/grammers-client/src/lib.rs @@ -0,0 +1,104 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! # Brief on Telegram's API +//! +//! This library is a high-level implementation to access [Telegram's API], which essentially +//! lets you automate everything you can do with official Telegram clients and more from Rust, +//! or even control bot accounts, making it a viable alternative to using the [Telegram Bot API]. +//! +//! In order to create an application with the library for people to use, you will need to +//! [obtain a developer API ID] first. You can embed it as a constant in your binary and ship +//! that to users (anyone can login, including yourself and bots, with the developer API ID; +//! they do *not* need to provide their own API ID). +//! +//! Once that's ready, create a new [`Client`] instance with its [`Client::new`] +//! method and start making API calls. +//! +//! # Method cost +//! +//! When a method is said to be "expensive", this often means that calling it too much in a +//! certain period of time will result in the API returning "flood wait" errors, meaning that +//! the method cannot be called again for a certain amount of seconds (trying to do so will +//! continue to return the flood wait error for that duration). +//! +//! On the other hand, a "cheap" method can be called a lot and is unlikely to result in a flood +//! wait error. However, all methods can potentially cause a flood wait, so some care is still +//! required. +//! +//! There is nothing wrong with causing the API to return flood wait errors, but it is something +//! to be avoided. Because they are expected, the default [`ClientConfiguration`] will sleep on +//! small flood errors to prevent interruption of simple scripts. +//! +//! A flood wait error is different from a peer flood error. A peer flood error means the flood +//! limitation is applied account-wide, and its duration is undefined. This often means that the +//! account spammed, or a young account tried to contact too many peers. +//! +//! # Re-exports +//! +//! For convenience and to ease using compatible traits in lockstep, this +//! library re-exports several modules and types from its direct dependencies. +//! +//! ## grammers-mtsender as sender +//! +//! `grammers_client` is purely a friendlier abstraction on top of the [`SenderPool`], +//! which is responsible for abstracting the connection wholesale. The [`InvocationError`] +//! is also re-exported at the top level, as all methods that interact with the API +//! eventually need to invoke the request and may thus fail. You may also use the +//! [`Result`] alias, which has the `InvocationError` set as its error variant. +//! +//! If you need access to the rest of the crate (e.g. to configure the `SenderPool`), +//! it is re-exported under [`sender`]. +//! +//! ## grammers-session as session +//! +//! After login, the generated Authorization Key should be persisted and reused, +//! in order to avoid logging in again, as that is a very expensive method. +//! +//! Session storages are responsible for doing so, and you may even implement your +//! own by using the [`session::Session`] trait. +//! +//! ## grammers-tl-types as tl +//! +//! This crate is re-exported wholesale under the [`tl`] namespace (short for +//! [Type Language](https://core.telegram.org/mtproto/TL)). All Telegram types and functions +//! are automatically generated from their public schema, which is versioned by "layers". +//! For now and the foreseeable future, *grammers* will only offer one layer at a time. +//! +//! When the friendly API of `grammers_client` falls short, you can reach out for its +//! raw [`tl::types`] to access all the information Telegram returned on its responses. +//! Additionally, if the [`Client`] is missing a method to do what you need, you may +//! directly [`Client::invoke`] any of the [`tl::functions`] offered by Telegram's API. +//! +//! Using raw types is **discouraged**. Not only are they far clunkier to use, but they +//! are also **not part of the semantic versioning**. While patch versions won't change +//! the layer, minor versions will, which would traditionally be considered a breaking change. +//! +//! [Telegram's API]: https://core.telegram.org/#telegram-api +//! [Telegram Bot API]: https://core.telegram.org/bots/api +//! [obtain a developer API ID]: https://my.telegram.org/auth +//! [`ClientConfiguration`]: crate::client::ClientConfiguration + +#![deny(unsafe_code)] + +pub mod client; +pub mod media; +pub mod message; +#[cfg(any(feature = "markdown", feature = "html"))] +pub mod parsers; +pub mod peer; +pub mod update; +pub(crate) mod utils; + +pub use client::{Client, SignInError}; +pub use grammers_mtsender::{self as sender, InvocationError, SenderPool}; +pub use grammers_session as session; +pub use grammers_tl_types as tl; + +/// Alias for [`std::result::Result`] with the error set to [`InvocationError`]. +pub type Result = std::result::Result; diff --git a/vendor/grammers-client/src/media/attributes.rs b/vendor/grammers-client/src/media/attributes.rs new file mode 100755 index 0000000..c790eff --- /dev/null +++ b/vendor/grammers-client/src/media/attributes.rs @@ -0,0 +1,80 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::convert::TryInto; +use std::time::Duration; + +use grammers_tl_types as tl; + +/// Document media attributes. +#[derive(Clone)] +pub enum Attribute { + Audio { + duration: Duration, + title: Option, + performer: Option, + }, + Voice { + duration: Duration, + waveform: Option>, + }, + Video { + round_message: bool, + supports_streaming: bool, + duration: Duration, + w: i32, + h: i32, + }, + FileName(String), +} + +impl From for tl::enums::DocumentAttribute { + fn from(attr: Attribute) -> Self { + use Attribute::*; + match attr { + Audio { + duration, + title, + performer, + } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: false, + duration: duration.as_secs().try_into().unwrap(), + title, + performer, + waveform: None, + }), + Voice { duration, waveform } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: true, + duration: duration.as_secs().try_into().unwrap(), + title: None, + performer: None, + waveform, + }), + Video { + round_message, + supports_streaming, + duration, + w, + h, + } => Self::Video(tl::types::DocumentAttributeVideo { + round_message, + supports_streaming, + nosound: false, + duration: duration.as_secs_f64(), + w, + h, + preload_prefix_size: None, + video_start_ts: None, + video_codec: None, + }), + FileName(file_name) => { + Self::Filename(tl::types::DocumentAttributeFilename { file_name }) + } + } + } +} diff --git a/vendor/grammers-client/src/media/downloadable.rs b/vendor/grammers-client/src/media/downloadable.rs new file mode 100755 index 0000000..8ec49f4 --- /dev/null +++ b/vendor/grammers-client/src/media/downloadable.rs @@ -0,0 +1,27 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +/// Trait implemented by all types that may be downloaded. +pub trait Downloadable { + /// Converts this downloadable into the input type that may be used by raw requests to download it. + fn to_raw_input_location(&self) -> Option; + + /// Returns `Some` if the media has a tiny thumbnail embedded within. + /// + /// No network request would occur when attempting to download this downloadable. + fn to_data(&self) -> Option> { + None + } + + /// File size, in bytes. + fn size(&self) -> Option { + None + } +} diff --git a/vendor/grammers-client/src/media/input_media.rs b/vendor/grammers-client/src/media/input_media.rs new file mode 100644 index 0000000..227233d --- /dev/null +++ b/vendor/grammers-client/src/media/input_media.rs @@ -0,0 +1,305 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::{Attribute, Media, Uploaded}; + +/// Media item to be sent as part of an album. +#[derive(Default)] +pub struct InputMedia { + pub(crate) entities: Vec, + pub(crate) reply_to: Option, + pub(crate) caption: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMedia { + /// Creates a new empty media message for input. + pub fn new() -> Self { + Self::default() + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMedia::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn caption(mut self, s: T) -> Self + where + T: Into, + { + self.caption = s.into(); + self + } + + /// The formatting entities within the caption (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new media from the given markdown-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// Builds a new media from the given HTML-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// The album identifier to which this album should reply to, if any. + /// + /// Otherwise, this album will not be a reply to any other. + /// + /// Only the reply_to from the first media is used. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// Include the uploaded file as a photo in the album. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the album. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the album. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let media = InputMedia::new().caption("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the media. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::{Attribute, InputMedia}; + /// + /// let media = InputMedia::new().caption("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the album. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} diff --git a/vendor/grammers-client/src/media/media.rs b/vendor/grammers-client/src/media/media.rs new file mode 100644 index 0000000..11ebf71 --- /dev/null +++ b/vendor/grammers-client/src/media/media.rs @@ -0,0 +1,887 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt::Debug; + +use chrono::{DateTime, Utc}; +use grammers_tl_types as tl; + +use super::{Downloadable, PhotoSize}; + +/// Photo media contained within a message. +#[derive(Clone, Debug, PartialEq)] +pub struct Photo { + pub raw: tl::types::MessageMediaPhoto, +} + +/// File documents unlike the alternative of compressed photos. +/// +/// This includes uncompressed photos, videos, stickers, and files of any type. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + pub raw: tl::types::MessageMediaDocument, +} + +/// A sticker [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Sticker { + pub document: Document, + pub raw_attrs: tl::types::DocumentAttributeSticker, + animated: bool, +} + +/// Data uploaded by the client to be used as either a [`Photo`] or [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Uploaded { + pub raw: tl::enums::InputFile, +} + +/// Contact media containing a phone number. +#[derive(Clone, Debug, PartialEq)] +pub struct Contact { + pub raw: tl::types::MessageMediaContact, +} + +/// Poll or quiz which may be voted on while open. +#[derive(Clone, Debug, PartialEq)] +pub struct Poll { + pub raw: tl::types::Poll, + pub raw_results: tl::types::PollResults, +} + +/// Geo point with latitude and longitude coordinates on the globe. +#[derive(Clone, Debug, PartialEq)] +pub struct Geo { + pub raw: tl::types::GeoPoint, +} + +/// Animated built-in sticker with a set of possible outcomes. +/// +/// Originally used to roll dice (🎲), but other emoji such as darts (🎯), +/// basket-balls (🏀) and slot-machines (🎰) can also be used. +#[derive(Clone, Debug, PartialEq)] +pub struct Dice { + pub raw: tl::types::MessageMediaDice, +} + +/// A place with optional coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Venue { + pub geo: Option, + pub raw_venue: tl::types::MessageMediaVenue, +} + +/// Like [`Geo`], but periodcally edits itself with updated information. +#[derive(Clone, Debug, PartialEq)] +pub struct GeoLive { + pub geo: Option, + pub raw_geolive: tl::types::MessageMediaGeoLive, +} + +/// Instant View web-page previews. +#[derive(Clone, Debug, PartialEq)] +pub struct WebPage { + pub raw: tl::types::MessageMediaWebPage, +} + +/// Profile picture of a group. +#[derive(Clone, Debug, PartialEq)] +pub struct ChatPhoto { + pub raw: tl::enums::InputFileLocation, +} + +/// Message media (e.g. photos, polls, videos). +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Media { + /// Compressed JPEG photo. + Photo(Photo), + /// File which may or not be rendered directly by clients. + Document(Document), + /// Document that has the attributes of a sticker. + Sticker(Sticker), + /// Contact information. + Contact(Contact), + /// Poll which can be voted on while open. + Poll(Poll), + /// Coordinates of a place. + Geo(Geo), + /// Dice-like built-in sticker media. + Dice(Dice), + /// Information about a place with optional coordinates. + Venue(Venue), + /// Self-updating coordinates of a place or person. + GeoLive(GeoLive), + /// Web-page instant view. + WebPage(WebPage), +} + +impl Photo { + pub fn from_raw(photo: tl::enums::Photo) -> Self { + Self { + raw: tl::types::MessageMediaPhoto { + spoiler: false, + photo: Some(photo), + ttl_seconds: None, + live_photo: false, + video: None, + }, + } + } + + pub fn from_raw_media(photo: tl::types::MessageMediaPhoto) -> Self { + Self { raw: photo } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPhoto { + use tl::{ + enums::{InputPhoto as eInputPhoto, Photo}, + types::InputPhoto, + }; + + tl::types::InputMediaPhoto { + spoiler: false, + id: match self.raw.photo { + Some(Photo::Photo(ref photo)) => InputPhoto { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + } + .into(), + _ => eInputPhoto::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + live_photo: false, + video: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Photo as P; + + match self.raw.photo.as_ref().unwrap() { + P::Empty(photo) => photo.id, + P::Photo(photo) => photo.id, + } + } + + /// The size of the photo, if not empty. + pub fn size(&self) -> Option { + self.thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|thumb| thumb.size()) + } + + /// Get photo thumbs. + /// + /// Since Telegram doesn't store the original photo, it can be presented in different sizes + /// and quality, a.k.a. thumbnails. Each photo preview has a specific type, indicating + /// the resolution and image transform that was applied server-side. Some low-resolution + /// thumbnails already contain all necessary information that can be shown to the user, but + /// for other types an additional request to the Telegram should be performed. + /// Check the description of [`PhotoSize``] to get an information about each particular thumbnail. + /// + /// + pub fn thumbs(&self) -> Vec { + use tl::enums::Photo as P; + + let photo = match self.raw.photo.as_ref() { + Some(photo) => photo, + None => return vec![], + }; + + match photo { + P::Empty(_) => vec![], + P::Photo(photo) => photo + .sizes + .iter() + .map(|x| PhotoSize::make_from(x, photo)) + .collect(), + } + } + + /// Returns true if the photo is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } + + /// Returns TTL seconds if the photo is self-destructive, None otherwise + pub fn ttl_seconds(&self) -> Option { + self.raw.ttl_seconds + } +} + +impl Downloadable for Photo { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Photo as P; + + self.raw.photo.as_ref().and_then(|p| match p { + P::Empty(_) => None, + P::Photo(photo) => Some( + tl::types::InputPhotoFileLocation { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + thumb_size: self + .thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|ps| ps.photo_type()) + .unwrap_or(String::from("w")), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Document { + pub fn from_raw_media(document: tl::types::MessageMediaDocument) -> Self { + Self { raw: document } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDocument { + use tl::{ + enums::{Document, InputDocument as eInputDocument}, + types::InputDocument, + }; + + tl::types::InputMediaDocument { + spoiler: false, + id: match self.raw.document { + Some(Document::Document(ref document)) => InputDocument { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + } + .into(), + _ => eInputDocument::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + query: None, + video_cover: None, + video_timestamp: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(document) => document.id, + D::Document(document) => document.id, + } + } + + /// Return the file's name. + /// + /// If the file was uploaded with no file name, the returned string will be empty. + /// + /// Note that the file name is not necessarily valid as seen by the OS, + /// and using it directly as the download location is subject to causing + /// path trasversal attacks. + pub fn name(&self) -> Option<&str> { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(_) => None, + D::Document(document) => document.attributes.iter().find_map(|attr| match attr { + tl::enums::DocumentAttribute::Filename(attr) => Some(attr.file_name.as_ref()), + _ => None, + }), + } + } + + /// Get the file's MIME type, if any. + pub fn mime_type(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.mime_type.as_str()), + _ => None, + } + } + + /// The date on which the file was created, if any. + pub fn creation_date(&self) -> Option> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + Some(DateTime::::from_timestamp(d.date as i64, 0).expect("date out of range")) + } + _ => None, + } + } + + /// The size of the file. + pub fn size(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.size as usize), + _ => None, + } + } + + /// Get document thumbs. + /// See . + pub fn thumbs(&self) -> Vec { + use tl::enums::Document as D; + + let document = match self.raw.document.as_ref() { + Some(document) => document, + None => return vec![], + }; + + match document { + D::Empty(_) => vec![], + D::Document(document) => match &document.thumbs { + Some(thumbs) => thumbs + .iter() + .map(|x| PhotoSize::make_from_document(x, document)) + .collect(), + None => vec![], + }, + } + } + + /// Duration of video/audio, in seconds. + pub fn duration(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some(v.duration), + tl::enums::DocumentAttribute::Audio(a) => return Some(a.duration as _), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Width & height of video/image, in pixels. + pub fn resolution(&self) -> Option<(i32, i32)> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some((v.w, v.h)), + tl::enums::DocumentAttribute::ImageSize(i) => return Some((i.w, i.h)), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Title of audio. + pub fn audio_title(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.title.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Performer (artist) of audio. + pub fn performer(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.performer.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Returns `true` if the document is an animated sticker. + pub fn is_animated(&self) -> bool { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Animated => return true, + _ => {} + } + } + false + } + _ => false, + } + } + + /// Returns `true` if the document is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } +} + +impl Downloadable for Document { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Document as D; + + self.raw.document.as_ref().and_then(|p| match p { + D::Empty(_) => None, + D::Document(document) => Some( + tl::types::InputDocumentFileLocation { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + thumb_size: String::new(), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Sticker { + pub fn from_document(document: &Document) -> Option { + match document.raw.document { + Some(tl::enums::Document::Document(ref doc)) => { + let mut animated = false; + let mut sticker_attrs: Option = None; + for attr in &doc.attributes { + match attr { + tl::enums::DocumentAttribute::Sticker(s) => sticker_attrs = Some(s.clone()), + tl::enums::DocumentAttribute::Animated => animated = true, + _ => (), + } + } + Some(Self { + document: document.clone(), + raw_attrs: sticker_attrs?, + animated, + }) + } + _ => None, + } + } + + /// Get the emoji associated with the sticker. + pub fn emoji(&self) -> &str { + self.raw_attrs.alt.as_str() + } + + /// Is this sticker an animated sticker? + pub fn is_animated(&self) -> bool { + self.animated + } +} + +impl Contact { + pub fn from_raw_media(contact: tl::types::MessageMediaContact) -> Self { + Self { raw: contact } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaContact { + tl::types::InputMediaContact { + phone_number: self.raw.phone_number.clone(), + first_name: self.raw.first_name.clone(), + last_name: self.raw.last_name.clone(), + vcard: self.raw.vcard.clone(), + } + } + + /// The contact's phone number, in international format. This field will always be a non-empty + /// string of digits, although there's no guarantee that the number actually exists. + pub fn phone_number(&self) -> &str { + self.raw.phone_number.as_str() + } + + /// The contact's first name. Although official clients will always send a non-empty string, + /// it is possible for this field to be empty when sent via different means. + pub fn first_name(&self) -> &str { + self.raw.first_name.as_str() + } + + /// The contact's last name. May be empty if it's not set by sender. + pub fn last_name(&self) -> &str { + self.raw.last_name.as_str() + } + + /// Contact information in [vCard format]. Applications such as Telegram Desktop leave this + /// field empty. The vCard version used in this field could be any. The field may also contain + /// arbitrary text when sent by non-official clients. + /// + /// [vCard format]: https://en.wikipedia.org/wiki/VCard + pub fn vcard(&self) -> &str { + self.raw.vcard.as_str() + } +} + +impl Poll { + pub fn from_raw_media(poll: tl::types::MessageMediaPoll) -> Self { + Self { + raw: match poll.poll { + tl::enums::Poll::Poll(poll) => poll, + }, + raw_results: match poll.results { + tl::enums::PollResults::Results(results) => *results, + }, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPoll { + tl::types::InputMediaPoll { + poll: grammers_tl_types::enums::Poll::Poll(self.raw.clone()), + correct_answers: None, + solution: None, + solution_entities: None, + attached_media: None, + solution_media: None, + } + } + + /// Returns the question asked by the poll. + pub fn question(&self) -> &grammers_tl_types::enums::TextWithEntities { + &self.raw.question + } + + /// Return `true` if the poll is a quiz with a set of correct answers. + pub fn is_quiz(&self) -> bool { + self.raw.quiz + } + + /// Returns `true` if the poll is closed. + pub fn closed(&self) -> bool { + self.raw.closed + } + + /// Returns an iterator over the poll answer options. + pub fn iter_answers(&self) -> impl Iterator { + self.raw.answers.iter() + } + + /// Total voters that took part in the vote. + /// + /// May be `None` if poll hasn't started. + pub fn total_voters(&self) -> Option { + self.raw_results.total_voters + } + + /// Returns details of the voters choices, + /// such as amount of voters per answer and whether the answer + /// is currently selected by the logged-in account. + pub fn iter_voters_summary( + &self, + ) -> Option> { + self.raw_results.results.as_ref().map(|results| { + results.iter().map(|result| match result { + tl::enums::PollAnswerVoters::Voters(voters) => voters, + }) + }) + } +} + +impl Geo { + pub fn from_raw_media(geo: tl::types::MessageMediaGeo) -> Option { + use tl::enums::GeoPoint as eGeoPoint; + + match &geo.geo { + eGeoPoint::Empty => None, + eGeoPoint::Point(point) => Some(Self { raw: point.clone() }), + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoPoint { + use tl::types::InputGeoPoint; + + tl::types::InputMediaGeoPoint { + geo_point: InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + } + .into(), + } + } + + pub fn to_raw_input_geo_point(&self) -> tl::enums::InputGeoPoint { + use tl::{enums::InputGeoPoint as eInputGeoPoint, types::InputGeoPoint}; + + eInputGeoPoint::Point(InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + }) + } + + /// Get the latitude of the location. + pub fn latitue(&self) -> f64 { + self.raw.lat + } + + /// Get the latitude of the location. + pub fn longitude(&self) -> f64 { + self.raw.long + } + + /// Get the accuracy of the geo location, in meters. + pub fn accuracy_radius(&self) -> Option { + self.raw.accuracy_radius + } +} + +impl Dice { + pub fn from_raw_media(dice: tl::types::MessageMediaDice) -> Self { + Self { raw: dice } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDice { + tl::types::InputMediaDice { + emoticon: self.raw.emoticon.clone(), + } + } + + /// Get the emoji of the dice. + pub fn emoji(&self) -> &str { + &self.raw.emoticon + } + + /// Get the value of the dice. + pub fn value(&self) -> i32 { + self.raw.value + } +} + +impl Venue { + pub fn from_raw_media(venue: tl::types::MessageMediaVenue) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: venue.geo.clone(), + }), + raw_venue: venue, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaVenue { + tl::types::InputMediaVenue { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + title: self.raw_venue.title.clone(), + address: self.raw_venue.address.clone(), + provider: self.raw_venue.provider.clone(), + venue_id: self.raw_venue.venue_id.clone(), + venue_type: self.raw_venue.venue_type.clone(), + } + } + + /// Returns the title of the venue. + pub fn title(&self) -> &str { + &self.raw_venue.title + } + + /// Returns the address of the venue. + pub fn address(&self) -> &str { + &self.raw_venue.address + } + + /// Returns the provider of the venue location. + pub fn provider(&self) -> &str { + &self.raw_venue.provider + } + + /// Returns the id of the venue. + pub fn venue_id(&self) -> &str { + &self.raw_venue.venue_id + } + + /// Returns the type of the venue. + pub fn venue_type(&self) -> &str { + &self.raw_venue.venue_type + } +} + +impl GeoLive { + pub fn from_raw_media(geolive: tl::types::MessageMediaGeoLive) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: geolive.geo.clone(), + }), + raw_geolive: geolive, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoLive { + tl::types::InputMediaGeoLive { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + heading: self.raw_geolive.heading, + period: Some(self.raw_geolive.period), + proximity_notification_radius: self.raw_geolive.proximity_notification_radius, + stopped: false, + } + } + + /// Returns the heading of the live location in degress (1-360). + pub fn heading(&self) -> Option { + self.raw_geolive.heading + } + + /// Returns the validity period of the live location. + pub fn period(&self) -> i32 { + self.raw_geolive.period + } + + /// Returns the radius of the proximity alert. + pub fn proximity_notification_radius(&self) -> Option { + self.raw_geolive.proximity_notification_radius + } +} + +impl WebPage { + pub fn from_raw_media(webpage: tl::types::MessageMediaWebPage) -> Self { + Self { raw: webpage } + } +} + +impl Uploaded { + pub fn from_raw(input_file: tl::enums::InputFile) -> Self { + Self { raw: input_file } + } + + pub(crate) fn name(&self) -> &str { + match &self.raw { + tl::enums::InputFile::File(f) => f.name.as_ref(), + tl::enums::InputFile::Big(f) => f.name.as_ref(), + tl::enums::InputFile::StoryDocument(_) => "", + } + } +} + +impl Media { + pub fn from_raw(media: tl::enums::MessageMedia) -> Option { + use tl::enums::MessageMedia as M; + + // TODO implement the rest + match media { + M::Empty => None, + M::Photo(photo) => Some(Self::Photo(Photo::from_raw_media(photo))), + M::Geo(geo) => Geo::from_raw_media(geo).map(Self::Geo), + M::Contact(contact) => Some(Self::Contact(Contact::from_raw_media(contact))), + M::Unsupported => None, + M::Document(document) => { + let document = Document::from_raw_media(document); + Some(if let Some(sticker) = Sticker::from_document(&document) { + Self::Sticker(sticker) + } else { + Self::Document(document) + }) + } + M::WebPage(webpage) => Some(Self::WebPage(WebPage::from_raw_media(webpage))), + M::Venue(venue) => Some(Self::Venue(Venue::from_raw_media(venue))), + M::Game(_) => None, + M::Invoice(_) => None, + M::GeoLive(geolive) => Some(Self::GeoLive(GeoLive::from_raw_media(geolive))), + M::Poll(poll) => Some(Self::Poll(Poll::from_raw_media(*poll))), + M::Dice(dice) => Some(Self::Dice(Dice::from_raw_media(dice))), + M::Story(_) => None, + M::Giveaway(_) => None, + M::GiveawayResults(_) => None, + M::PaidMedia(_) => None, + M::ToDo(_) => None, + M::VideoStream(_) => None, + } + } + + pub fn size(&self) -> Option { + match self { + Media::Photo(photo) => photo.size(), + Media::Document(document) => document.size(), + Media::Sticker(sticker) => sticker.document.size(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + pub fn to_raw_input_media(&self) -> Option { + match self { + Media::Photo(photo) => Some(photo.to_raw_input_media().into()), + Media::Document(document) => Some(document.to_raw_input_media().into()), + Media::Sticker(sticker) => Some(sticker.document.to_raw_input_media().into()), + Media::Contact(contact) => Some(contact.to_raw_input_media().into()), + Media::Poll(poll) => Some(poll.to_raw_input_media().into()), + Media::Geo(geo) => Some(geo.to_raw_input_media().into()), + Media::Dice(dice) => Some(dice.to_raw_input_media().into()), + Media::Venue(venue) => Some(venue.to_raw_input_media().into()), + Media::GeoLive(geolive) => Some(geolive.to_raw_input_media().into()), + Media::WebPage(_) => None, + } + } +} + +impl Downloadable for Media { + fn to_raw_input_location(&self) -> Option { + match self { + Media::Photo(photo) => photo.to_raw_input_location(), + Media::Document(document) => document.to_raw_input_location(), + Media::Sticker(sticker) => sticker.document.to_raw_input_location(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + fn size(&self) -> Option { + self.size() + } +} + +impl From for Media { + fn from(photo: Photo) -> Self { + Self::Photo(photo) + } +} + +impl Downloadable for ChatPhoto { + fn to_raw_input_location(&self) -> Option { + Some(self.raw.clone()) + } +} diff --git a/vendor/grammers-client/src/media/mod.rs b/vendor/grammers-client/src/media/mod.rs new file mode 100644 index 0000000..dfcb653 --- /dev/null +++ b/vendor/grammers-client/src/media/mod.rs @@ -0,0 +1,56 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to message media and downloadables. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod attributes; +mod downloadable; +mod input_media; +mod media; +mod photo_sizes; + +pub use attributes::Attribute; +pub use downloadable::Downloadable; +pub use input_media::InputMedia; +pub use media::{ + ChatPhoto, Contact, Dice, Document, Geo, GeoLive, Media, Photo, Poll, Sticker, Uploaded, Venue, + WebPage, +}; +pub use photo_sizes::PhotoSize; + +use grammers_tl_types as tl; + +/// Build an `InputMediaUploadedDocument` from an uploaded file. +/// +/// Shared by the `document` (`force_file = false`) and `file` (`force_file = true`) +/// builder methods of `InputMedia` and `InputMessage`. +pub(crate) fn uploaded_document( + file: Uploaded, + mime_type: String, + force_file: bool, + ttl_seconds: Option, +) -> tl::enums::InputMedia { + let file_name = file.name().to_string(); + tl::types::InputMediaUploadedDocument { + nosound_video: false, + force_file, + spoiler: false, + file: file.raw, + thumb: None, + mime_type, + attributes: vec![(tl::types::DocumentAttributeFilename { file_name }).into()], + stickers: None, + ttl_seconds, + video_cover: None, + video_timestamp: None, + } + .into() +} diff --git a/vendor/grammers-client/src/media/photo_sizes.rs b/vendor/grammers-client/src/media/photo_sizes.rs new file mode 100644 index 0000000..73bb941 --- /dev/null +++ b/vendor/grammers-client/src/media/photo_sizes.rs @@ -0,0 +1,398 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::Downloadable; + +/// Build the raw input file location for a photo or document thumbnail. +fn raw_input_location( + from_document: bool, + id: i64, + access_hash: i64, + file_reference: Vec, + thumb_size: String, +) -> tl::enums::InputFileLocation { + if !from_document { + tl::types::InputPhotoFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } else { + tl::types::InputDocumentFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } +} + +/// Thumbnail of a [`Photo`] or [`Document`]. +/// +/// [`Photo`]: super::Photo +/// [`Document`]: super::Document +#[derive(Clone, Debug, PartialEq)] +pub enum PhotoSize { + /// Size that is no longer available. + Empty(SizeEmpty), + /// Size that may be downloaded. + Size(Size), + /// Size attached directly to the message object. + Cached(CachedSize), + /// Stripped size attached directly to the message object. + Stripped(StrippedSize), + /// Multiple sizes in one. + Progressive(ProgressiveSize), + /// Vector thumbnail. + Path(PathSize), +} + +impl PhotoSize { + pub(crate) fn make_from(size: &tl::enums::PhotoSize, photo: &tl::types::Photo) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + pub(crate) fn make_from_document( + size: &tl::enums::PhotoSize, + document: &tl::types::Document, + ) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + /// Size of the photo thumb. + pub fn size(&self) -> usize { + match self { + PhotoSize::Empty(_) => 0, + PhotoSize::Size(size) => size.size as usize, + PhotoSize::Cached(size) => size.bytes.len(), + PhotoSize::Stripped(size) => { + let bytes = &size.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return 0; + } + size.bytes.len() + 622 + } + PhotoSize::Progressive(size) => { + size.sizes.iter().max().unwrap_or(&0).to_owned() as usize + } + PhotoSize::Path(size) => size.bytes.len(), + } + } + + /// Resolution and image transform applied server-side. + /// + /// + pub fn photo_type(&self) -> String { + match self { + PhotoSize::Empty(size) => size.photo_type.clone(), + PhotoSize::Size(size) => size.photo_type.clone(), + PhotoSize::Cached(size) => size.photo_type.clone(), + PhotoSize::Stripped(size) => size.photo_type.clone(), + PhotoSize::Progressive(size) => size.photo_type.clone(), + PhotoSize::Path(size) => size.photo_type.clone(), + } + } +} + +impl Downloadable for PhotoSize { + fn to_raw_input_location(&self) -> Option { + match self { + PhotoSize::Size(size) => size.to_raw_input_location(), + PhotoSize::Empty(_) => None, + PhotoSize::Cached(_) => None, + PhotoSize::Stripped(_) => None, + PhotoSize::Progressive(size) => size.to_raw_input_location(), + PhotoSize::Path(_) => None, + } + } + + fn to_data(&self) -> Option> { + match self { + PhotoSize::Empty(_) => None, + PhotoSize::Size(_) => None, + PhotoSize::Cached(size) => Some(size.data()), + PhotoSize::Stripped(size) => Some(size.data()), + PhotoSize::Progressive(_) => None, + PhotoSize::Path(size) => Some(size.data()), + } + } +} + +/// Empty thumbnail. Image with this thumbnail is unavailable. +#[derive(Clone, Debug, PartialEq)] +pub struct SizeEmpty { + photo_type: String, +} + +/// Image description. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct Size { + photo_type: String, + pub width: i32, + pub height: i32, + pub size: i32, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl Size { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Description of an image and its content. +#[derive(Clone, Debug, PartialEq)] +pub struct CachedSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub bytes: Vec, +} + +impl CachedSize { + fn data(&self) -> Vec { + self.bytes.clone() + } +} + +/// A low-resolution compressed JPG payload +#[derive(Clone, Debug, PartialEq)] +pub struct StrippedSize { + photo_type: String, + + pub bytes: Vec, +} + +impl StrippedSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#stripped-thumbnails + let bytes = &self.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return vec![]; + } + + let header = vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x28, 0x1c, 0x1e, + 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d, 0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, + 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58, 0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, + 0x80, 0x8c, 0x8a, 0xa0, 0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, + 0xff, 0xcb, 0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xff, 0xfa, + 0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d, 0x2d, 0x3c, + 0x35, 0x3c, 0x76, 0x41, 0x41, 0x76, 0xf8, 0xa5, 0x8c, 0xa5, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, + 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, + 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, + 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, + 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, + 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, + 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, + 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, + 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, + 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, + 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, + 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, + 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, + 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, + ]; + let mut footer = vec![0xff, 0xd9]; + let mut real = header; + real[164] = bytes[1]; + real[166] = bytes[2]; + + let mut bytes_clone = bytes.clone()[3..].to_vec(); + real.append(&mut bytes_clone); + real.append(&mut footer); + + real + } +} + +/// Progressively encoded photosize. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct ProgressiveSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub sizes: Vec, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl ProgressiveSize { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Messages with animated stickers can have a compressed svg (< 300 bytes) to show the outline +/// of the sticker before fetching the actual lottie animation. +#[derive(Clone, Debug, PartialEq)] +pub struct PathSize { + photo_type: String, + + pub bytes: Vec, +} + +impl PathSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#vector-thumbnails + let lookup = "AACAAAAHAAALMAAAQASTAVAAAZaacaaaahaaalmaaaqastava.az0123456789-,"; + let mut path = String::from("M"); + for num in &self.bytes { + let num = *num; + if num >= 128 + 64 { + path.push(lookup.chars().nth((num - 128 - 64) as usize).unwrap()); + } else { + if num >= 128 { + path.push(','); + } else if num >= 64 { + path.push('-'); + } + path.push((num & 63) as char); + } + } + path.push('z'); + let res = format!( + r###" + + +"### + ); + + res.as_bytes().to_vec() + } +} diff --git a/vendor/grammers-client/src/message/button.rs b/vendor/grammers-client/src/message/button.rs new file mode 100755 index 0000000..aac9913 --- /dev/null +++ b/vendor/grammers-client/src/message/button.rs @@ -0,0 +1,179 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Custom keyboards and inline buttons used by messages. + +use grammers_tl_types as tl; + +/// Inline button to be used as the reply markup underneath a message. +pub struct Button { + pub raw: tl::enums::KeyboardButton, +} + +/// Key to be used as the reply markup of an alternate virtual keyboard. +pub struct Key { + pub raw: tl::enums::KeyboardButton, +} + +impl Button { + /// Creates a button that will trigger a [`crate::update::CallbackQuery`] when clicked. + /// + /// Although any combination of bytes can be used (including null, and not just UTF-8), [there is + /// a limit](https://core.telegram.org/bots/api#inlinekeyboardbutton) to how long the payload data + /// can be (see the description for the `callback_data` field for an up-to-date value). If you + /// need to store more data than that, consider storing the real data in some form of database, + /// and a reference to that data's row in the button's payload. + /// + /// Both the text and bytes data must be non-empty. + pub fn data, B: Into>>(text: T, bytes: B) -> Button { + Button { + raw: tl::types::KeyboardButtonCallback { + text: text.into(), + data: bytes.into(), + requires_password: false, + style: None, + } + .into(), + } + } + + /// Creates a button to force the user to switch to inline mode (perform inline queries). + /// + /// Pressing the button will insert the bot's username and the specified inline query in the input + /// field. + pub fn switch, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: true, + style: None, + peer_types: None, + } + .into(), + } + } + /// Creates a button identical to [`Self::switch`], except the user will be prompted to select a + /// different peer. + /// + /// Pressing the button will prompt the user to select one of their peers, open that peer and + /// insert the bot's username and the specified inline query in the input field. + pub fn switch_elsewhere, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: false, + style: None, + peer_types: None, + } + .into(), + } + } + + /// Creates a button that when clicked will ask the user if they want to open the specified URL. + /// + /// The URL will be visible to the user before it's opened unless it's trusted (such as Telegram's + /// domain). + pub fn url, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonUrl { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } + + /// Creates a button that when clicked will open the specified URL in an in-app browser. + pub fn webview, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonWebView { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } +} + +impl Key { + /// Creates a simple keyboard key. + /// + /// When pressed, the button's text will be sent as a normal message, as if the user had typed it. + pub fn text>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButton { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's contact information (including the phone). + pub fn request_phone>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPhone { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's current geo-location. + pub fn request_geo>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestGeoLocation { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key that will direct the user to create and send a poll when pressed. + /// + /// This is only available in direct conversations with the user. + pub fn request_poll>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: None, + } + .into(), + } + } + + /// Creates a keyboard key identical to [`Self::request_poll`], except the poll requested must be a quiz. + /// + /// This is only available in direct conversations with the user. + pub fn request_quiz>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: Some(true), + } + .into(), + } + } +} + +/* +TODO implement other buttons +(with password) keyboardButtonCallback#35bbdb6b flags:# requires_password:flags.0?true text:string data:bytes = KeyboardButton; +keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +keyboardButtonGame#50f41ccf text:string = KeyboardButton; +keyboardButtonBuy#afd93fbb text:string = KeyboardButton; +inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +*/ diff --git a/vendor/grammers-client/src/message/input_message.rs b/vendor/grammers-client/src/message/input_message.rs new file mode 100644 index 0000000..5ef23d4 --- /dev/null +++ b/vendor/grammers-client/src/message/input_message.rs @@ -0,0 +1,427 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use grammers_tl_types as tl; + +use super::reply_markup::ReplyMarkup; +use crate::media::{Attribute, Media, Uploaded}; + +// https://github.com/telegramdesktop/tdesktop/blob/e7fbcce9d9f0a8944eb2c34e74bd01b8776cb891/Telegram/SourceFiles/data/data_scheduled_messages.h#L52 +const SCHEDULE_ONCE_ONLINE: i32 = 0x7ffffffe; + +/// Construct and send rich text messages with various options. +#[derive(Clone, Default)] +pub struct InputMessage { + pub(crate) background: bool, + pub(crate) clear_draft: bool, + pub(crate) entities: Vec, + pub(crate) invert_media: bool, + pub(crate) link_preview: bool, + pub(crate) reply_markup: Option, + pub(crate) reply_to: Option, + pub(crate) schedule_date: Option, + pub(crate) silent: bool, + pub(crate) text: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMessage { + /// Creates a new empty message for input. + pub fn new() -> Self { + Self::default() + } + + /// Whether to "send this message as a background message". + /// + /// This description is taken from . + pub fn background(mut self, background: bool) -> Self { + self.background = background; + self + } + + /// Whether the draft in this peer, if any, should be cleared. + pub fn clear_draft(mut self, clear_draft: bool) -> Self { + self.clear_draft = clear_draft; + self + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMessage::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn text(mut self, s: T) -> Self + where + T: Into, + { + self.text = s.into(); + self + } + + /// The formatting entities within the message (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new message from the given markdown-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Builds a new message from the given HTML-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Whether the media will be inverted. + /// + /// If inverted, photos, videos, and documents will appear at the bottom and link previews at the top of the message. + pub fn invert_media(mut self, invert_media: bool) -> Self { + self.invert_media = invert_media; + self + } + + /// Whether the link preview be shown for the message. + /// + /// This has no effect when sending media, which cannot contain a link preview. + pub fn link_preview(mut self, link_preview: bool) -> Self { + self.link_preview = link_preview; + self + } + + /// Defines the suggested reply markup for the message (such as adding inline buttons). + /// This will be displayed below the message. + /// + /// Only bot accounts can make use of the reply markup feature (a user attempting to send a + /// message with a reply markup will result in the markup being ignored by Telegram). + /// + /// The user is free to ignore the markup and continue sending usual text messages. + /// + /// See [`crate::message::ReplyMarkup`] for the different available markups along with how + /// they behave. + pub fn reply_markup(mut self, markup: ReplyMarkup) -> Self { + self.reply_markup = Some(markup.raw); + self + } + + /// The message identifier to which this message should reply to, if any. + /// + /// Otherwise, this message will not be a reply to any other. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// If set to a distant enough future time, the message won't be sent immediately, + /// and instead it will be scheduled to be automatically sent at a later time. + /// + /// This scheduling is done server-side, and may not be accurate to the second. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_date(mut self, schedule_date: Option) -> Self { + self.schedule_date = schedule_date.map(|t| { + t.duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i32) + .unwrap_or(0) + }); + self + } + + /// Schedule the message to be sent once the person comes online. + /// + /// This only works in private chats, and only if the person has their + /// last seen visible. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_once_online(mut self) -> Self { + self.schedule_date = Some(SCHEDULE_ONCE_ONLINE); + self + } + + /// Whether the message should notify people or not. + /// + /// Defaults to `false`, which means it will notify them. Set it to `true` + /// to alter this behaviour. + pub fn silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } + + /// Include the uploaded file as a photo in the message. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the message. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the message. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let message = InputMessage::new().text("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the message. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::Attribute; + /// use grammers_client::message::InputMessage; + /// + /// let message = InputMessage::new().text("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the message. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} + +impl From for InputMessage +where + S: Into, +{ + fn from(text: S) -> Self { + Self::new().text(text) + } +} + +impl From for InputMessage { + fn from(message: super::Message) -> Self { + let (text, entities, media) = match message.raw { + tl::enums::Message::Empty(_) => (None, None, None), + tl::enums::Message::Message(message) => { + (Some(message.message), message.entities, message.media) + } + tl::enums::Message::Service(_) => (None, None, None), + }; + + Self { + text: text.unwrap_or_default(), + entities: entities.unwrap_or_default(), + media: media + .and_then(Media::from_raw) + .and_then(|m| m.to_raw_input_media()), + ..Default::default() + } + } +} diff --git a/vendor/grammers-client/src/message/message.rs b/vendor/grammers-client/src/message/message.rs new file mode 100644 index 0000000..4f27465 --- /dev/null +++ b/vendor/grammers-client/src/message/message.rs @@ -0,0 +1,842 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; +#[cfg(feature = "fs")] +use std::path::Path; + +use chrono::{DateTime, Utc}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{InputMessage, InputReactions}; +use crate::Client; +use crate::media::{InputMedia, Media, Photo}; +#[cfg(any(feature = "markdown", feature = "html"))] +use crate::parsers; +use crate::peer::{Peer, PeerMap}; +use crate::utils; + +/// Represents a Telegram message, which includes text messages, messages with media, and service +/// messages. +/// +/// This message should be treated as a snapshot in time, that is, if the message is edited while +/// using this object, those changes won't alter this structure. +#[derive(Clone)] +pub struct Message { + pub raw: tl::enums::Message, + pub(crate) fetched_in: Option, + pub(crate) client: Client, + // When fetching messages or receiving updates, a set of peers will be present. A single + // server response contains a lot of peers, and some might be related to deep layers of + // a message action for instance. Keeping the entire set like this allows for cheaper clones + // and moves, and saves us from worrying about picking out all the peers we care about. + pub(crate) peers: PeerMap, +} + +impl Message { + pub fn from_raw( + client: &Client, + message: tl::enums::Message, + fetched_in: Option, + peers: PeerMap, + ) -> Self { + Self { + raw: message, + fetched_in, + client: client.clone(), + peers, + } + } + + pub fn from_raw_short_updates( + client: &Client, + updates: tl::types::UpdateShortSentMessage, + input: InputMessage, + peer: PeerRef, + ) -> Self { + Self { + raw: tl::enums::Message::Message(tl::types::Message { + out: updates.out, + mentioned: false, + media_unread: false, + silent: input.silent, + post: false, // TODO true if sent to broadcast channel + from_scheduled: false, + legacy: false, + edit_hide: false, + pinned: false, + noforwards: false, // TODO true if channel has noforwads? + video_processing_pending: false, + paid_suggested_post_stars: false, + invert_media: input.invert_media, + id: updates.id, + from_id: None, // TODO self + from_boosts_applied: None, + from_rank: None, + peer_id: peer.id.into(), + saved_peer_id: None, + fwd_from: None, + via_bot_id: None, + reply_to: input.reply_to.map(|reply_to_msg_id| { + tl::types::MessageReplyHeader { + reply_to_scheduled: false, + forum_topic: false, + quote: false, + reply_to_ephemeral: false, + reply_to_msg_id: Some(reply_to_msg_id), + reply_to_peer_id: None, + reply_from: None, + reply_media: None, + reply_to_top_id: None, + quote_text: None, + quote_entities: None, + quote_offset: None, + todo_item_id: None, + poll_option: None, + } + .into() + }), + date: updates.date, + message: input.text, + media: updates.media, + reply_markup: input.reply_markup, + entities: updates.entities, + views: None, + forwards: None, + replies: None, + edit_date: None, + post_author: None, + grouped_id: None, + restriction_reason: None, + ttl_period: updates.ttl_period, + reactions: None, + quick_reply_shortcut_id: None, + via_business_bot_id: None, + guestchat_via_from: None, + offline: false, + effect: None, + factcheck: None, + report_delivery_until_date: None, + paid_message_stars: None, + paid_suggested_post_ton: false, + suggested_post: None, + schedule_repeat_period: None, + summary_from_language: None, + rich_message: None, + }), + fetched_in: Some(peer), + client: client.clone(), + peers: client.empty_peer_map(), + } + } + + /// Whether the message is outgoing (i.e. you sent this message to some other peer) or + /// incoming (i.e. someone else sent it to you or the peer). + pub fn outgoing(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.out, + tl::enums::Message::Service(message) => message.out, + } + } + + /// Whether you were mentioned in this message or not. + /// + /// This includes @username mentions, text mentions, and messages replying to one of your + /// previous messages (even if it contains no mention in the message text). + pub fn mentioned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.mentioned, + tl::enums::Message::Service(message) => message.mentioned, + } + } + + /// Whether you have read the media in this message or not. + /// + /// Most commonly, these are voice notes that you have not played yet. + pub fn media_unread(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.media_unread, + tl::enums::Message::Service(message) => message.media_unread, + } + } + + /// Whether the message should notify people with sound or not. + pub fn silent(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.silent, + tl::enums::Message::Service(message) => message.silent, + } + } + + /// Whether this message is a post in a broadcast channel or not. + pub fn post(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.post, + tl::enums::Message::Service(message) => message.post, + } + } + + /// Whether this message was originated from a previously-scheduled message or not. + pub fn from_scheduled(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.from_scheduled, + tl::enums::Message::Service(_) => false, + } + } + + // `legacy` is not exposed, though it can be if it proves to be useful + + /// Whether the edited mark of this message is edited should be hidden (e.g. in GUI clients) + /// or shown. + pub fn edit_hide(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.edit_hide, + tl::enums::Message::Service(_) => false, + } + } + + /// Whether this message is currently pinned or not. + pub fn pinned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.pinned, + tl::enums::Message::Service(_) => false, + } + } + + /// The ID of this message. + /// + /// Message identifiers are counters that start at 1 and grow by 1 for each message produced. + /// + /// Every channel has its own unique message counter. This counter is the same for all users, + /// but unique to each channel. + /// + /// Every account has another unique message counter which is used for private conversations + /// and small group chats. This means different accounts will likely have different message + /// identifiers for the same message in a private conversation or small group chat. This also + /// implies that the message identifier alone is enough to uniquely identify the message, + /// without the need to know the chat ID. + /// + /// **You cannot use the message ID of User A when running as User B**, unless this message + /// belongs to a megagroup or broadcast channel. Beware of this when using methods like + /// [`Client::delete_messages`], which **cannot** validate the peer where the message + /// should be deleted for those cases. + pub fn id(&self) -> i32 { + self.raw.id() + } + + /// The [`Self::peer`]'s identifier. + pub fn peer_id(&self) -> PeerId { + utils::peer_from_message(&self.raw) + .map(PeerId::from) + .or_else(|| self.fetched_in.map(|peer| peer.id)) + .expect("empty messages from updates should contain peer_id") + } + + /// Cached reference to the [`Self::peer`], if it is in cache. + pub async fn peer_ref( + &self, + ) -> Result, Box> { + match self.fetched_in { + Some(peer) => Ok(Some(peer)), + None => self.peers.get_ref(self.peer_id()).await, + } + } + + /// The peer where this message was sent to. + /// + /// This might be the user you're talking to for private conversations, or the group or + /// channel where the message was sent. + pub fn peer(&self) -> Option<&Peer> { + self.peers.get(self.peer_id()) + } + + /// The [`Self::sender`]'s identifier, if there is a sender. + pub fn sender_id(&self) -> Option { + let from_id = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.from_id.clone().map(PeerId::from), + tl::enums::Message::Service(message) => message.from_id.clone().map(PeerId::from), + }; + from_id.or_else(|| { + // Incoming messages in private conversations don't include `from_id` since + // layer 119, but the sender can only be the peer we're in. + let peer_id = self.peer_id(); + if matches!(peer_id.kind(), PeerKind::User) { + if self.outgoing() { + Some(PeerId::self_user()) + } else { + Some(peer_id) + } + } else { + None + } + }) + } + + /// Cached reference to the [`Self::sender`], if there is a sender and they are in cache. + pub async fn sender_ref( + &self, + ) -> Result, Box> { + match self.sender_id() { + None => Ok(None), + Some(x) => self.peers.get_ref(x).await, + } + } + + /// The sender of this message, if there is a sender **and** the sender is in cache. + pub fn sender(&self) -> Option<&Peer> { + self.sender_id().and_then(|id| self.peers.get(id)) + } + + /// If this message was forwarded from a previous message, return the header with information + /// about that forward. + pub fn forward_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.fwd_from.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent @via some inline bot, return the bot's user identifier. + pub fn via_bot_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.via_bot_id, + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is replying to a previous message, return the header with information + /// about that reply. + pub fn reply_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_to.clone(), + tl::enums::Message::Service(message) => message.reply_to.clone(), + } + } + + pub(crate) fn date_timestamp(&self) -> i32 { + match &self.raw { + tl::enums::Message::Empty(_) => 0, + tl::enums::Message::Message(message) => message.date, + tl::enums::Message::Service(message) => message.date, + } + } + + /// The date when this message was produced. + pub fn date(&self) -> DateTime { + utils::date(self.date_timestamp()) + } + + /// The message's text. + /// + /// For service or empty messages, this will be the empty strings. + /// + /// If the message has media, this text is the caption commonly displayed underneath it. + pub fn text(&self) -> &str { + match &self.raw { + tl::enums::Message::Empty(_) => "", + tl::enums::Message::Message(message) => &message.message, + tl::enums::Message::Service(_) => "", + } + } + + fn entities(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.entities.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a markdown string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + /// + /// Formatting entities which cannot be represented in CommonMark without resorting to HTML, + /// such as underline, are also ignored. + #[cfg(feature = "markdown")] + pub fn markdown_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_markdown_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a HTML string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + #[cfg(feature = "html")] + pub fn html_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_html_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// The media displayed by this message, if any. + /// + /// This not only includes photos or videos, but also contacts, polls, documents, locations + /// and many other types. + pub fn media(&self) -> Option { + let media = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.media.clone(), + tl::enums::Message::Service(_) => None, + }; + media.and_then(Media::from_raw) + } + + /// If the message has a reply markup (which can happen for messages produced by bots), + /// returns said markup. + pub fn reply_markup(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_markup.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// The formatting entities used to format this message, such as bold, italic, with their + /// offsets and lengths. + pub fn fmt_entities(&self) -> Option<&Vec> { + // TODO correct the offsets and lengths to match the byte offsets + self.entities() + } + + /// How many views does this message have, when applicable. + /// + /// The same user account can contribute to increment this counter indefinitedly, however + /// there is a server-side cooldown limitting how fast it can happen (several hours). + pub fn view_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.views, + tl::enums::Message::Service(_) => None, + } + } + + /// How many times has this message been forwarded, when applicable. + pub fn forward_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.forwards, + tl::enums::Message::Service(_) => None, + } + } + + /// How many replies does this message have, when applicable. + pub fn reply_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + replies: Some(tl::enums::MessageReplies::Replies(replies)), + .. + }) => Some(replies.replies), + _ => None, + } + } + + /// React to this message. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// message.react("👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// message.react(reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// message.react(InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn react>( + &self, + reactions: R, + ) -> Result<(), InvocationError> { + self.client + .send_reactions( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + reactions, + ) + .await?; + Ok(()) + } + + /// How many reactions does this message have, when applicable. + pub fn reaction_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reactions: Some(tl::enums::MessageReactions::Reactions(reactions)), + .. + }) => { + let count = reactions + .results + .iter() + .map(|reaction: &tl::enums::ReactionCount| { + let tl::enums::ReactionCount::Count(reaction) = reaction; + reaction.count + }) + .sum(); + Some(count) + } + _ => None, + } + } + + /// The date when this message was last edited. + pub fn edit_date(&self) -> Option> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.edit_date.map(utils::date), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent to a channel, return the name used by the author to post it. + pub fn post_author(&self) -> Option<&str> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.post_author.as_deref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message belongs to a group of messages, return the unique identifier for that + /// group. + /// + /// This applies to albums of media, such as multiple photos grouped together. + /// + /// Note that there may be messages sent in between the messages forming a group. + pub fn grouped_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.grouped_id, + tl::enums::Message::Service(_) => None, + } + } + + /// A list of reasons on why this message is restricted. + /// + /// The message is not restricted if the return value is `None`. + pub fn restriction_reason(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.restriction_reason.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is a service message, return the service action that occured. + pub fn action(&self) -> Option<&tl::enums::MessageAction> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(_) => None, + tl::enums::Message::Service(message) => Some(&message.action), + } + } + + /// If this message is replying to another message, return the replied message ID. + pub fn reply_to_message_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reply_to: Some(tl::enums::MessageReplyHeader::Header(header)), + .. + }) => header.reply_to_msg_id, + _ => None, + } + } + + /// Fetch the message that this message is replying to, or `None` if this message is not a + /// reply to a previous message. + /// + /// Shorthand for `Client::get_reply_to_message`. + pub async fn get_reply(&self) -> Result, InvocationError> { + self.client + .clone() // TODO don't clone + .get_reply_to_message(self) + .await + } + + /// Respond to this message by sending a new message to the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_message`. + pub async fn respond>( + &self, + message: M, + ) -> Result { + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message, + ) + .await + } + + /// Respond to this message by sending a album in the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_album`. + pub async fn respond_album( + &self, + medias: Vec, + ) -> Result>, InvocationError> { + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Directly reply to this message by sending a new message to the same peer that replies to + /// it. This methods overrides the `reply_to` on the `InputMessage` to point to `self`. + /// + /// Shorthand for `Client::send_message`. + pub async fn reply>(&self, message: M) -> Result { + let message = message.into(); + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message.reply_to(Some(self.id())), + ) + .await + } + + /// Directly reply to this message by sending a album to the same peer that replies to + /// it. This methods overrides the `reply_to` on the first `InputMedia` to point to `self`. + /// + /// Shorthand for `Client::send_album`. + pub async fn reply_album( + &self, + mut medias: Vec, + ) -> Result>, InvocationError> { + medias.first_mut().unwrap().reply_to = Some(self.id()); + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Forward this message to another (or the same) peer. + /// + /// Shorthand for `Client::forward_messages`. If you need to forward multiple messages + /// at once, consider using that method instead. + pub async fn forward_to>(&self, chat: C) -> Result { + // TODO return `Message` + // When forwarding a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single forwarded message present which we can unwrap. + self.client + .forward_messages( + chat, + &[self.id()], + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + ) + .await + .map(|mut msgs| msgs.pop().unwrap().unwrap()) + } + + /// Edit this message to change its text or media. + /// + /// Shorthand for `Client::edit_message`. + pub async fn edit>(&self, new_message: M) -> Result<(), InvocationError> { + self.client + .edit_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + new_message, + ) + .await + } + + /// Delete this message for everyone. + /// + /// Shorthand for `Client::delete_messages`. If you need to delete multiple messages + /// at once, consider using that method instead. + pub async fn delete(&self) -> Result<(), InvocationError> { + self.client + .delete_messages( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await + .map(drop) + } + + /// Mark this message and all messages above it as read. + /// + /// Unlike `Client::mark_as_read`, this method only will mark the conversation as read up to + /// this message, not the entire conversation. + pub async fn mark_as_read(&self) -> Result<(), InvocationError> { + let peer = self.peer_ref().await?.ok_or(InvocationError::Dropped)?; + if peer.id.kind() == PeerKind::Channel { + self.client + .invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } else { + self.client + .invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } + } + + /// Pin this message in the conversation. + /// + /// Shorthand for `Client::pin_message`. + pub async fn pin(&self) -> Result<(), InvocationError> { + self.client + .pin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Unpin this message from the conversation. + /// + /// Shorthand for `Client::unpin_message`. + pub async fn unpin(&self) -> Result<(), InvocationError> { + self.client + .unpin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Refetch this message, mutating all of its properties in-place. + /// + /// No changes will be made to the message if it fails to be fetched. + /// + /// Shorthand for `Client::get_messages_by_id`. + pub async fn refetch(&self) -> Result<(), InvocationError> { + // When fetching a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single message present which we can unwrap. + self.client + .get_messages_by_id( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await? + .pop() + .unwrap() + .unwrap(); + todo!("actually mutate self after get_messages_by_id returns `Message`") + } + + /// Download the message media in this message if applicable. + /// + /// Returns `true` if there was media to download, or `false` otherwise. + /// + /// Shorthand for `Client::download_media`. + #[cfg(feature = "fs")] + pub async fn download_media>(&self, path: P) -> Result { + // TODO probably encode failed download in error + if let Some(media) = self.media() { + self.client.download_media(&media, path).await.map(|_| true) + } else { + Ok(false) + } + } + + /// Get photo attached to the message if any. + pub fn photo(&self) -> Option { + if let Media::Photo(photo) = self.media()? { + return Some(photo); + } + + None + } +} + +impl fmt::Debug for Message { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Message") + .field("id", &self.id()) + .field("outgoing", &self.outgoing()) + .field("date", &self.date()) + .field("text", &self.text()) + .field("peer", &self.peer()) + .field("sender", &self.sender()) + .field("reply_to_message_id", &self.reply_to_message_id()) + .field("via_bot_id", &self.via_bot_id()) + .field("media", &self.media()) + .field("mentioned", &self.mentioned()) + .field("media_unread", &self.media_unread()) + .field("silent", &self.silent()) + .field("post", &self.post()) + .field("from_scheduled", &self.from_scheduled()) + .field("edit_hide", &self.edit_hide()) + .field("pinned", &self.pinned()) + .field("forward_header", &self.forward_header()) + .field("reply_header", &self.reply_header()) + .field("reply_markup", &self.reply_markup()) + .field("fmt_entities", &self.fmt_entities()) + .field("view_count", &self.view_count()) + .field("forward_count", &self.forward_count()) + .field("reply_count", &self.reply_count()) + .field("edit_date", &self.edit_date()) + .field("post_author", &self.post_author()) + .field("grouped_id", &self.grouped_id()) + .field("restriction_reason", &self.restriction_reason()) + .field("action", &self.action()) + .finish() + } +} diff --git a/vendor/grammers-client/src/message/mod.rs b/vendor/grammers-client/src/message/mod.rs new file mode 100644 index 0000000..0f866a6 --- /dev/null +++ b/vendor/grammers-client/src/message/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to chat messages. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod button; +mod input_message; +mod message; +mod reactions; +mod reply_markup; + +pub use button::{Button, Key}; +pub use input_message::InputMessage; +pub use message::Message; +pub use reactions::InputReactions; +pub use reply_markup::ReplyMarkup; diff --git a/vendor/grammers-client/src/message/reactions.rs b/vendor/grammers-client/src/message/reactions.rs new file mode 100755 index 0000000..3f1f4f5 --- /dev/null +++ b/vendor/grammers-client/src/message/reactions.rs @@ -0,0 +1,85 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types::{self as tl, enums::Reaction}; + +/// Parameter for reacting to messages with emoji. +#[derive(Clone, Debug, Default)] +pub struct InputReactions { + pub(crate) reactions: Vec, + pub(crate) add_to_recent: bool, + pub(crate) big: bool, +} + +impl InputReactions { + /// Make reaction animation big. + pub fn big(mut self) -> Self { + self.big = true; + self + } + + /// Add this reaction to the recent reactions list. + /// + /// [Read more about recent reactions](https://core.telegram.org/api/reactions#recent-reactions). + pub fn add_to_recent(mut self) -> Self { + self.add_to_recent = true; + self + } + + /// Create new InputReactions with one emoticon reaction + pub fn emoticon>(emoticon: S) -> Self { + Self { + reactions: vec![Reaction::Emoji(tl::types::ReactionEmoji { + emoticon: emoticon.into(), + })], + ..Self::default() + } + } + + /// Create new InputReactions with one custom emoji reaction + pub fn custom_emoji(document_id: i64) -> Self { + Self { + reactions: vec![Reaction::CustomEmoji(tl::types::ReactionCustomEmoji { + document_id, + })], + ..Self::default() + } + } + + /// Create an empty InputReactions which will remove reactions + pub fn remove() -> Self { + Self::default() + } +} + +impl From for InputReactions { + fn from(val: String) -> Self { + InputReactions::emoticon(val) + } +} + +impl From<&str> for InputReactions { + fn from(val: &str) -> Self { + InputReactions::emoticon(val) + } +} + +impl From> for InputReactions { + fn from(reactions: Vec) -> Self { + Self { + reactions, + ..Self::default() + } + } +} + +impl From for Vec { + fn from(val: InputReactions) -> Self { + val.reactions + } +} diff --git a/vendor/grammers-client/src/message/reply_markup.rs b/vendor/grammers-client/src/message/reply_markup.rs new file mode 100755 index 0000000..bf91912 --- /dev/null +++ b/vendor/grammers-client/src/message/reply_markup.rs @@ -0,0 +1,291 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains functions to build reply markups. +//! +//! These can only be used by bot accounts when sending +//! messages through [`InputMessage::reply_markup`]. +//! +//! Each function returns a concrete builder-like type that +//! may be further configured via their inherent methods. +//! +//! The trait is used to group all types as "something that +//! may be used as a reply markup". +//! +//! [`InputMessage::reply_markup`]: crate::message::InputMessage::reply_markup + +use grammers_tl_types as tl; + +use super::{Button, Key}; + +const EMPTY_KEYBOARD_MARKUP: tl::types::ReplyKeyboardMarkup = tl::types::ReplyKeyboardMarkup { + resize: false, + single_use: false, + selective: false, + persistent: false, + rows: Vec::new(), + placeholder: None, +}; + +/// Markup to be used as the intended way to reply to the message it is attached to. +pub struct ReplyMarkup { + pub raw: tl::enums::ReplyMarkup, +} + +impl ReplyMarkup { + /// Define inline buttons for a message. + /// + /// These will display right under the message. + /// + /// You cannot add images to the buttons, but you can use emoji (simply copy-paste them into your + /// code, or use the correct escape sequence, or using any other input methods you like). + /// + /// You will need to provide a matrix of [`Button`], that is, a vector that contains the + /// rows from top to bottom, where the rows consist of a vector of buttons from left to right. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client, peer: grammers_session::types::PeerRef) -> Result<(), Box> { + /// use grammers_client::message::{InputMessage, ReplyMarkup, Button}; + /// + /// let artist = "Krewella"; + /// let markup = ReplyMarkup::from_buttons(&[ + /// vec![Button::data(format!("Song by {}", artist), b"play")], + /// vec![Button::data("Previous", b"prev"), Button::data("Next", b"next")], + /// ]); + /// client.send_message(peer, InputMessage::new().text("Select song").reply_markup(markup)).await?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_buttons(buttons: &[Vec