init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
.cache/
|
||||||
55
CMakeLists.txt
Normal file
55
CMakeLists.txt
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.21)
|
||||||
|
|
||||||
|
project(shinoa VERSION 0.1.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
|
include(FetchContent)
|
||||||
|
|
||||||
|
option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF)
|
||||||
|
|
||||||
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
|
find_package(Curses REQUIRED)
|
||||||
|
|
||||||
|
if(TELEGRAM_TUI_USE_SYSTEM_TDLIB)
|
||||||
|
find_package(Td REQUIRED)
|
||||||
|
else()
|
||||||
|
set(TD_ENABLE_JNI OFF CACHE BOOL "" FORCE)
|
||||||
|
set(TD_ENABLE_DOTNET OFF CACHE BOOL "" FORCE)
|
||||||
|
set(TD_ENABLE_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(TD_ENABLE_BENCHMARKS OFF CACHE BOOL "" FORCE)
|
||||||
|
set(TD_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
|
||||||
|
set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE)
|
||||||
|
|
||||||
|
FetchContent_Declare(
|
||||||
|
tdlib
|
||||||
|
GIT_REPOSITORY https://github.com/tdlib/td.git
|
||||||
|
GIT_TAG 8921c22f0f85b3cb0b56303f9cba81ba8549f4e8
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(tdlib)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_executable(
|
||||||
|
shinoa
|
||||||
|
src/app_attachments.cpp
|
||||||
|
src/app_chats.cpp
|
||||||
|
src/app.cpp
|
||||||
|
src/app_auth.cpp
|
||||||
|
src/app_state.cpp
|
||||||
|
src/main.cpp
|
||||||
|
src/models.cpp
|
||||||
|
src/td_client.cpp
|
||||||
|
src/util.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS})
|
||||||
|
target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES})
|
||||||
|
|
||||||
|
if(TELEGRAM_TUI_USE_SYSTEM_TDLIB)
|
||||||
|
target_link_libraries(shinoa PRIVATE Td::TdJson)
|
||||||
|
else()
|
||||||
|
target_link_libraries(shinoa PRIVATE Td::TdJson)
|
||||||
|
endif()
|
||||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# telegram-tui
|
||||||
|
|
||||||
|
A minimal Telegram terminal client built with `ncurses` and TDLib.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- interactive login flow inside the TUI
|
||||||
|
- chat list in the left pane
|
||||||
|
- message view in the right pane
|
||||||
|
- send plain text messages
|
||||||
|
- scroll chats and message history with the keyboard
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- CMake 3.21+
|
||||||
|
- a C++17 compiler
|
||||||
|
- `ncurses`
|
||||||
|
- TDLib build dependencies (`gperf`, `openssl`, `zlib`, `git`)
|
||||||
|
|
||||||
|
The project vendors TDLib automatically by default. If you already have TDLib installed with CMake package metadata, configure with `-DTELEGRAM_TUI_USE_SYSTEM_TDLIB=ON`.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build
|
||||||
|
cmake --build build -j
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Create a Telegram application at <https://my.telegram.org/apps>, then either export credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TELEGRAM_API_ID=123456
|
||||||
|
export TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef
|
||||||
|
./build/telegram-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
Or start the app without env vars and enter them interactively when prompted.
|
||||||
|
When entered in the TUI, the app now stores `api_id` and `api_hash` in
|
||||||
|
`~/.local/share/telegram-tui/config.json` and reuses them on later launches.
|
||||||
|
|
||||||
|
To use Telegram test servers instead of production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TELEGRAM_USE_TEST_DC=1
|
||||||
|
./build/telegram-tui
|
||||||
|
```
|
||||||
|
|
||||||
|
The client stores TDLib state in `~/.local/share/telegram-tui/tdlib` for production and `~/.local/share/telegram-tui/test/tdlib` for test mode.
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
- `Up` / `Down`: move selection
|
||||||
|
- `Tab`: switch focus between chats and messages
|
||||||
|
- `Enter`: open the selected chat
|
||||||
|
- `i`: start composing a message
|
||||||
|
- `PgUp` / `PgDn`: scroll the current message view
|
||||||
|
- `r`: reload chats or history
|
||||||
|
- `Esc`: cancel current input
|
||||||
|
- `q`: quit
|
||||||
1610
src/app.cpp
Normal file
1610
src/app.cpp
Normal file
File diff suppressed because it is too large
Load Diff
186
src/app.h
Normal file
186
src/app.h
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <chrono>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <map>
|
||||||
|
#include <optional>
|
||||||
|
#include <tuple>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "json.h"
|
||||||
|
#include "models.h"
|
||||||
|
#include "td_client.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
class App {
|
||||||
|
public:
|
||||||
|
App();
|
||||||
|
int run();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void init_curses();
|
||||||
|
void shutdown_curses();
|
||||||
|
bool process_updates();
|
||||||
|
void handle_td_object(const json& object);
|
||||||
|
void handle_authorization_state();
|
||||||
|
void send_tdlib_parameters();
|
||||||
|
void send_check_phone_number();
|
||||||
|
void persist_config();
|
||||||
|
void request_chat_details(std::int64_t chat_id);
|
||||||
|
void set_open_chat(std::int64_t chat_id);
|
||||||
|
void open_forward_target_menu(std::int64_t source_chat_id, std::vector<std::int64_t> message_ids);
|
||||||
|
void start_input(InputMode mode, std::string prompt, bool hidden);
|
||||||
|
void clear_input();
|
||||||
|
void submit_input();
|
||||||
|
void send_message(std::int64_t chat_id, const std::string& text, std::optional<std::int64_t> reply_to_message_id = std::nullopt);
|
||||||
|
void forward_message(
|
||||||
|
std::int64_t source_chat_id,
|
||||||
|
const std::vector<std::int64_t>& message_ids,
|
||||||
|
std::int64_t target_chat_id);
|
||||||
|
void send_photo_message(
|
||||||
|
std::int64_t chat_id,
|
||||||
|
const std::string& photo_path,
|
||||||
|
const std::string& caption,
|
||||||
|
std::optional<std::int64_t> reply_to_message_id = std::nullopt);
|
||||||
|
bool preview_clipboard_photo_message(
|
||||||
|
std::int64_t chat_id,
|
||||||
|
const std::string& caption,
|
||||||
|
std::optional<std::int64_t> reply_to_message_id = std::nullopt);
|
||||||
|
void request_more_chats();
|
||||||
|
bool request_chat_history(std::int64_t chat_id, bool force);
|
||||||
|
bool request_open_chat_history(bool force);
|
||||||
|
void sync_chat_ids_from_response(const json& response);
|
||||||
|
void mark_chat_messages_as_read(std::int64_t chat_id);
|
||||||
|
void mark_message_as_read(std::int64_t chat_id, std::int64_t message_id);
|
||||||
|
void update_user_status(std::int64_t user_id, const json& status);
|
||||||
|
void upsert_user(const json& user);
|
||||||
|
void upsert_basic_group(const json& basic_group);
|
||||||
|
void upsert_supergroup(const json& supergroup);
|
||||||
|
void upsert_chat(const json& chat_object);
|
||||||
|
void apply_chat_position(ChatInfo& chat, const json& position);
|
||||||
|
void resort_chats();
|
||||||
|
void append_message(std::int64_t chat_id, MessageInfo message);
|
||||||
|
void remove_message(std::int64_t chat_id, std::int64_t message_id);
|
||||||
|
void merge_history(std::int64_t chat_id, const json& messages);
|
||||||
|
void update_attachment_file(const json& file);
|
||||||
|
void start_reply_to_latest_message();
|
||||||
|
void request_attachment_download(const AttachmentInfo& attachment, bool open_after_download);
|
||||||
|
void open_attachment(const AttachmentInfo& attachment);
|
||||||
|
void download_attachment(const AttachmentInfo& attachment);
|
||||||
|
void delete_attachment(const AttachmentInfo& attachment);
|
||||||
|
bool export_attachment_to_downloads(const AttachmentInfo& attachment);
|
||||||
|
bool play_video_attachment(const AttachmentInfo& attachment);
|
||||||
|
void refresh_attachment_viewer_content(const AttachmentInfo& attachment);
|
||||||
|
[[nodiscard]] std::vector<std::string> attachment_animation_frames(const AttachmentInfo& attachment) const;
|
||||||
|
bool advance_attachment_animation();
|
||||||
|
[[nodiscard]] std::string attachment_preview_path(const AttachmentInfo& attachment) const;
|
||||||
|
[[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo& attachment, int width, int height) const;
|
||||||
|
void clear_attachment_preview_graphics();
|
||||||
|
void render_attachment_preview_graphics(int top, int left, int width, int height);
|
||||||
|
void reset_attachment_viewer_send_preview();
|
||||||
|
[[nodiscard]] std::vector<std::int64_t> forward_target_chat_ids() const;
|
||||||
|
[[nodiscard]] std::optional<AttachmentInfo> selected_attachment() const;
|
||||||
|
[[nodiscard]] std::string render_attachment_preview(const AttachmentInfo& attachment, int width, int height) const;
|
||||||
|
[[nodiscard]] std::string build_attachment_preview_graphics(const AttachmentInfo& attachment, int width, int height) const;
|
||||||
|
[[nodiscard]] std::tuple<bool, std::int64_t, std::string> parse_compose_command(const std::string& value) const;
|
||||||
|
[[nodiscard]] std::optional<std::vector<std::int64_t>> parse_forward_command(const std::string& value) const;
|
||||||
|
[[nodiscard]] std::optional<std::size_t> find_message_index(const ChatInfo& chat, std::int64_t message_id) const;
|
||||||
|
[[nodiscard]] std::string format_message_ref(const ChatInfo& chat, std::int64_t message_id) const;
|
||||||
|
[[nodiscard]] std::string sender_label(const json& sender) const;
|
||||||
|
[[nodiscard]] std::string user_status_label(std::int64_t user_id) const;
|
||||||
|
[[nodiscard]] std::string forward_origin_label(const json& forward_info) const;
|
||||||
|
[[nodiscard]] std::string via_bot_label(std::int64_t via_bot_user_id) const;
|
||||||
|
[[nodiscard]] std::string preview_message(const json& message) const;
|
||||||
|
[[nodiscard]] std::optional<AttachmentInfo> parse_attachment(const json& content) const;
|
||||||
|
[[nodiscard]] std::string content_to_text(const json& content, bool decorate) const;
|
||||||
|
[[nodiscard]] MessageInfo parse_message(const json& message) const;
|
||||||
|
[[nodiscard]] std::string format_open_chat_header(const ChatInfo& chat) const;
|
||||||
|
void handle_key(int ch);
|
||||||
|
void handle_wide_char(wint_t ch);
|
||||||
|
void handle_input_key(int ch);
|
||||||
|
void handle_forward_target_menu_key(int ch);
|
||||||
|
void handle_help_menu_key(int ch);
|
||||||
|
void handle_attachments_menu_key(int ch);
|
||||||
|
void handle_attachment_action_menu_key(int ch);
|
||||||
|
void handle_attachment_viewer_key(int ch);
|
||||||
|
void draw();
|
||||||
|
void init_colors();
|
||||||
|
void draw_chat_pane(int top, int height, int width);
|
||||||
|
void draw_message_pane(int top, int height, int left, int width);
|
||||||
|
void draw_help_menu(int height, int width);
|
||||||
|
void draw_forward_target_menu(int height, int width);
|
||||||
|
void draw_attachments_menu(int height, int width);
|
||||||
|
void draw_attachment_action_menu(int height, int width);
|
||||||
|
void draw_attachment_viewer(int height, int width);
|
||||||
|
[[nodiscard]] std::optional<std::int64_t> highlighted_chat_id() const;
|
||||||
|
[[nodiscard]] std::optional<std::int64_t> open_chat_id() const;
|
||||||
|
[[nodiscard]] std::string current_auth_label() const;
|
||||||
|
|
||||||
|
TdClient td_;
|
||||||
|
std::map<std::int64_t, UserInfo> users_;
|
||||||
|
std::map<std::int64_t, ChatInfo> chats_;
|
||||||
|
std::vector<std::int64_t> sorted_chat_ids_;
|
||||||
|
|
||||||
|
std::filesystem::path database_dir_;
|
||||||
|
std::filesystem::path files_dir_;
|
||||||
|
|
||||||
|
bool running_ = true;
|
||||||
|
bool authorized_ = false;
|
||||||
|
bool attachments_menu_open_ = false;
|
||||||
|
bool attachment_action_menu_open_ = false;
|
||||||
|
bool attachment_viewer_open_ = false;
|
||||||
|
bool forward_target_menu_open_ = false;
|
||||||
|
bool help_menu_open_ = false;
|
||||||
|
bool input_hidden_ = false;
|
||||||
|
bool auto_reload_chat_history_ = false;
|
||||||
|
bool use_test_dc_ = false;
|
||||||
|
|
||||||
|
FocusPane focus_ = FocusPane::Chats;
|
||||||
|
InputMode input_mode_ = InputMode::None;
|
||||||
|
|
||||||
|
int selected_chat_index_ = 0;
|
||||||
|
int message_scroll_ = 0;
|
||||||
|
int attachment_category_index_ = 0;
|
||||||
|
int attachment_selection_index_ = 0;
|
||||||
|
int attachment_action_index_ = 0;
|
||||||
|
int attachment_viewer_scroll_ = 0;
|
||||||
|
int forward_target_index_ = 0;
|
||||||
|
std::int64_t open_chat_id_ = 0;
|
||||||
|
std::int64_t tdlib_open_chat_id_ = 0;
|
||||||
|
std::int64_t my_user_id_ = 0;
|
||||||
|
std::int64_t forward_source_chat_id_ = 0;
|
||||||
|
std::size_t input_cursor_ = 0;
|
||||||
|
|
||||||
|
std::string api_id_;
|
||||||
|
std::string api_hash_;
|
||||||
|
std::string phone_number_;
|
||||||
|
std::string pending_first_name_;
|
||||||
|
|
||||||
|
std::string input_prompt_;
|
||||||
|
std::string input_buffer_;
|
||||||
|
std::string status_line_ = "Starting TDLib...";
|
||||||
|
std::string attachment_preview_graphics_data_;
|
||||||
|
std::string attachment_viewer_title_;
|
||||||
|
std::string attachment_preview_signature_;
|
||||||
|
std::vector<std::string> attachment_viewer_lines_;
|
||||||
|
std::vector<std::string> attachment_viewer_animation_frames_;
|
||||||
|
json authorization_state_ = json::object();
|
||||||
|
std::optional<AttachmentInfo> pending_attachment_open_;
|
||||||
|
std::optional<AttachmentInfo> pending_attachment_download_;
|
||||||
|
std::optional<AttachmentInfo> attachment_viewer_attachment_;
|
||||||
|
std::optional<std::int64_t> attachment_viewer_send_reply_to_message_id_;
|
||||||
|
std::chrono::steady_clock::time_point attachment_viewer_next_frame_at_{};
|
||||||
|
std::int64_t attachment_viewer_send_chat_id_ = 0;
|
||||||
|
bool attachment_preview_graphics_visible_ = false;
|
||||||
|
bool attachment_preview_graphics_is_sixel_ = false;
|
||||||
|
bool attachment_viewer_send_on_enter_ = false;
|
||||||
|
bool attachment_viewer_is_animated_ = false;
|
||||||
|
std::size_t attachment_viewer_frame_index_ = 0;
|
||||||
|
std::string attachment_viewer_send_caption_;
|
||||||
|
std::vector<std::int64_t> forward_message_ids_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
1356
src/app_attachments.cpp
Normal file
1356
src/app_attachments.cpp
Normal file
File diff suppressed because it is too large
Load Diff
669
src/app_auth.cpp
Normal file
669
src/app_auth.cpp
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <set>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include <curses.h>
|
||||||
|
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct ClipboardImageType {
|
||||||
|
const char* mime = "";
|
||||||
|
const char* extension = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr std::array<ClipboardImageType, 5> kClipboardImageTypes = {{
|
||||||
|
{"image/png", ".png"},
|
||||||
|
{"image/jpeg", ".jpg"},
|
||||||
|
{"image/webp", ".webp"},
|
||||||
|
{"image/bmp", ".bmp"},
|
||||||
|
{"image/tiff", ".tiff"},
|
||||||
|
}};
|
||||||
|
|
||||||
|
std::string shell_quote(const std::string& value) {
|
||||||
|
std::string quoted = "'";
|
||||||
|
for (char ch : value) {
|
||||||
|
if (ch == '\'') {
|
||||||
|
quoted += "'\\''";
|
||||||
|
} else {
|
||||||
|
quoted.push_back(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quoted.push_back('\'');
|
||||||
|
return quoted;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string run_command_capture(const std::string& command) {
|
||||||
|
FILE* pipe = popen(command.c_str(), "r");
|
||||||
|
if (pipe == nullptr) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string output;
|
||||||
|
char buffer[4096];
|
||||||
|
while (std::fgets(buffer, sizeof(buffer), pipe) != nullptr) {
|
||||||
|
output += buffer;
|
||||||
|
}
|
||||||
|
if (pclose(pipe) != 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool command_exists(const char* command) {
|
||||||
|
const std::string resolved = run_command_capture("command -v " + std::string(command) + " 2>/dev/null");
|
||||||
|
return !trim_copy(resolved).empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string clipboard_capture_command(const std::string& mime_type, const std::string& destination_path) {
|
||||||
|
if (command_exists("wl-paste")) {
|
||||||
|
return "wl-paste --no-newline --type " + shell_quote(mime_type) + " > " +
|
||||||
|
shell_quote(destination_path) + " 2>/dev/null";
|
||||||
|
}
|
||||||
|
if (command_exists("xclip")) {
|
||||||
|
return "xclip -selection clipboard -t " + shell_quote(mime_type) + " -o > " +
|
||||||
|
shell_quote(destination_path) + " 2>/dev/null";
|
||||||
|
}
|
||||||
|
if (command_exists("pngpaste") && mime_type == "image/png") {
|
||||||
|
return "pngpaste " + shell_quote(destination_path) + " >/dev/null 2>&1";
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<ClipboardImageType> detect_clipboard_image_type() {
|
||||||
|
if (command_exists("wl-paste")) {
|
||||||
|
const std::string types_output = run_command_capture("wl-paste --list-types 2>/dev/null");
|
||||||
|
for (const auto& type : kClipboardImageTypes) {
|
||||||
|
if (types_output.find(type.mime) != std::string::npos) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command_exists("xclip")) {
|
||||||
|
const std::string targets_output =
|
||||||
|
run_command_capture("xclip -selection clipboard -t TARGETS -o 2>/dev/null");
|
||||||
|
for (const auto& type : kClipboardImageTypes) {
|
||||||
|
if (targets_output.find(type.mime) != std::string::npos) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (command_exists("pngpaste")) {
|
||||||
|
return ClipboardImageType{"image/png", ".png"};
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> parse_clipboard_compose_command(const std::string& value) {
|
||||||
|
static constexpr std::array<const char*, 3> kPrefixes = {">paste", ">clip", ">screenshot"};
|
||||||
|
for (const char* prefix : kPrefixes) {
|
||||||
|
if (value == prefix) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
const std::string with_space = std::string(prefix) + " ";
|
||||||
|
if (value.rfind(with_space, 0) == 0) {
|
||||||
|
return trim_copy(value.substr(with_space.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::string> strip_forward_prefix(const std::string& value) {
|
||||||
|
static constexpr std::array<const char*, 3> kPrefixes = {">f", ">fw", ">forward"};
|
||||||
|
for (const char* prefix : kPrefixes) {
|
||||||
|
if (value == prefix) {
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
const std::string with_space = std::string(prefix) + " ";
|
||||||
|
if (value.rfind(with_space, 0) == 0) {
|
||||||
|
return trim_copy(value.substr(with_space.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
json markdown_formatted_text(TdClient& td, const std::string& text) {
|
||||||
|
json formatted_text = {
|
||||||
|
{"@type", "formattedText"},
|
||||||
|
{"text", text},
|
||||||
|
{"entities", json::array()},
|
||||||
|
};
|
||||||
|
if (const auto parsed = td.execute({
|
||||||
|
{"@type", "parseMarkdown"},
|
||||||
|
{"text", formatted_text},
|
||||||
|
});
|
||||||
|
parsed.has_value() && safe_string(*parsed, "@type") == "formattedText") {
|
||||||
|
formatted_text = *parsed;
|
||||||
|
}
|
||||||
|
return formatted_text;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void App::handle_authorization_state() {
|
||||||
|
const std::string type = safe_string(authorization_state_, "@type");
|
||||||
|
if (type == "authorizationStateWaitTdlibParameters") {
|
||||||
|
if (api_id_.empty()) {
|
||||||
|
start_input(InputMode::ApiId, "Telegram API ID", false);
|
||||||
|
status_line_ = "Enter your Telegram application API ID.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (api_hash_.empty()) {
|
||||||
|
start_input(InputMode::ApiHash, "Telegram API hash", false);
|
||||||
|
status_line_ = "Enter your Telegram application API hash.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_tdlib_parameters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateWaitPhoneNumber") {
|
||||||
|
if (phone_number_.empty()) {
|
||||||
|
start_input(InputMode::PhoneNumber, "Phone number", false);
|
||||||
|
status_line_ = "Enter phone number for Telegram account.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_check_phone_number();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateWaitEncryptionKey") {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "checkDatabaseEncryptionKey"},
|
||||||
|
{"encryption_key", ""},
|
||||||
|
});
|
||||||
|
status_line_ = "Unlocking local database...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateWaitCode") {
|
||||||
|
start_input(InputMode::AuthCode, "Login code", false);
|
||||||
|
status_line_ = "Enter code Telegram sent.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateWaitPassword") {
|
||||||
|
start_input(InputMode::Password, "2FA password", true);
|
||||||
|
status_line_ = "Two-step verification enabled.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateWaitRegistration") {
|
||||||
|
if (pending_first_name_.empty()) {
|
||||||
|
start_input(InputMode::FirstName, "First name", false);
|
||||||
|
status_line_ = "New account. Enter first name.";
|
||||||
|
} else {
|
||||||
|
start_input(InputMode::LastName, "Last name", false);
|
||||||
|
status_line_ = "Enter optional last name.";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateReady") {
|
||||||
|
authorized_ = true;
|
||||||
|
input_mode_ = InputMode::None;
|
||||||
|
status_line_ = "Authorized. Loading chats.";
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getMe"},
|
||||||
|
{"@extra", "getMe"},
|
||||||
|
});
|
||||||
|
request_more_chats();
|
||||||
|
if (!open_chat_id().has_value()) {
|
||||||
|
const auto chat_id = highlighted_chat_id();
|
||||||
|
if (chat_id.has_value()) {
|
||||||
|
set_open_chat(*chat_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request_open_chat_history(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateLoggingOut") {
|
||||||
|
authorized_ = false;
|
||||||
|
status_line_ = "Logging out...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "authorizationStateClosed") {
|
||||||
|
authorized_ = false;
|
||||||
|
status_line_ = "TDLib session closed.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::send_tdlib_parameters() {
|
||||||
|
if (!is_decimal_number(api_id_)) {
|
||||||
|
start_input(InputMode::ApiId, "Telegram API ID", false);
|
||||||
|
status_line_ = "Telegram API ID must be numeric.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line_ = "Sending TDLib parameters...";
|
||||||
|
td_.send({
|
||||||
|
{"@type", "setTdlibParameters"},
|
||||||
|
{"use_test_dc", use_test_dc_},
|
||||||
|
{"database_directory", database_dir_.string()},
|
||||||
|
{"files_directory", files_dir_.string()},
|
||||||
|
{"database_encryption_key", ""},
|
||||||
|
{"use_file_database", true},
|
||||||
|
{"use_chat_info_database", true},
|
||||||
|
{"use_message_database", true},
|
||||||
|
{"use_secret_chats", true},
|
||||||
|
{"api_id", std::stoi(api_id_)},
|
||||||
|
{"api_hash", api_hash_},
|
||||||
|
{"system_language_code", "en"},
|
||||||
|
{"device_model", "shinoa"},
|
||||||
|
{"system_version", "Linux"},
|
||||||
|
{"application_version", "0.1.0"},
|
||||||
|
{"enable_storage_optimizer", true},
|
||||||
|
{"ignore_file_names", false},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::send_check_phone_number() {
|
||||||
|
status_line_ = "Requesting login code...";
|
||||||
|
td_.send({
|
||||||
|
{"@type", "setAuthenticationPhoneNumber"},
|
||||||
|
{"phone_number", phone_number_},
|
||||||
|
{"settings", {{"@type", "phoneNumberAuthenticationSettings"}}},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::persist_config() {
|
||||||
|
if (!save_app_config(StoredConfig{api_id_, api_hash_, auto_reload_chat_history_})) {
|
||||||
|
status_line_ = "Failed to save config locally.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::request_chat_details(std::int64_t chat_id) {
|
||||||
|
auto chat_it = chats_.find(chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatInfo& chat = chat_it->second;
|
||||||
|
if (chat.details_requested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.private_user_id != 0) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getUser"},
|
||||||
|
{"user_id", chat.private_user_id},
|
||||||
|
});
|
||||||
|
chat.details_requested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.basic_group_id != 0) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getBasicGroup"},
|
||||||
|
{"basic_group_id", chat.basic_group_id},
|
||||||
|
});
|
||||||
|
chat.details_requested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.supergroup_id != 0) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getSupergroup"},
|
||||||
|
{"supergroup_id", chat.supergroup_id},
|
||||||
|
});
|
||||||
|
chat.details_requested = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::start_input(InputMode mode, std::string prompt, bool hidden) {
|
||||||
|
input_mode_ = mode;
|
||||||
|
input_prompt_ = std::move(prompt);
|
||||||
|
input_hidden_ = hidden;
|
||||||
|
if (mode != InputMode::Compose) {
|
||||||
|
input_buffer_.clear();
|
||||||
|
}
|
||||||
|
input_cursor_ = input_buffer_.size();
|
||||||
|
curs_set(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::clear_input() {
|
||||||
|
input_mode_ = InputMode::None;
|
||||||
|
input_prompt_.clear();
|
||||||
|
input_buffer_.clear();
|
||||||
|
input_cursor_ = 0;
|
||||||
|
input_hidden_ = false;
|
||||||
|
curs_set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::submit_input() {
|
||||||
|
const std::string value = trim_copy(input_buffer_);
|
||||||
|
if (value.empty()) {
|
||||||
|
status_line_ = "Input cannot be empty.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputMode mode = input_mode_;
|
||||||
|
if (mode == InputMode::ApiId && !is_decimal_number(value)) {
|
||||||
|
status_line_ = "Telegram API ID must be numeric.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear_input();
|
||||||
|
|
||||||
|
if (mode == InputMode::Compose) {
|
||||||
|
const auto chat_id = open_chat_id();
|
||||||
|
if (!chat_id.has_value()) {
|
||||||
|
status_line_ = "Open chat first.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (const auto message_ids = parse_forward_command(value); message_ids.has_value()) {
|
||||||
|
open_forward_target_menu(*chat_id, *message_ids);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const auto [is_reply, reply_to_message_id, text] = parse_compose_command(value);
|
||||||
|
if (const auto clipboard_caption = parse_clipboard_compose_command(text); clipboard_caption.has_value()) {
|
||||||
|
preview_clipboard_photo_message(
|
||||||
|
*chat_id,
|
||||||
|
*clipboard_caption,
|
||||||
|
is_reply ? std::optional<std::int64_t>(reply_to_message_id) : std::nullopt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.empty()) {
|
||||||
|
status_line_ = "Reply text cannot be empty.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send_message(*chat_id, text, is_reply ? std::optional<std::int64_t>(reply_to_message_id) : std::nullopt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::ApiId) {
|
||||||
|
api_id_ = value;
|
||||||
|
persist_config();
|
||||||
|
if (api_hash_.empty()) {
|
||||||
|
start_input(InputMode::ApiHash, "Telegram API hash", false);
|
||||||
|
} else {
|
||||||
|
send_tdlib_parameters();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::ApiHash) {
|
||||||
|
api_hash_ = value;
|
||||||
|
persist_config();
|
||||||
|
send_tdlib_parameters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::PhoneNumber) {
|
||||||
|
phone_number_ = value;
|
||||||
|
send_check_phone_number();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::AuthCode) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "checkAuthenticationCode"},
|
||||||
|
{"code", value},
|
||||||
|
});
|
||||||
|
status_line_ = "Submitting code...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::Password) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "checkAuthenticationPassword"},
|
||||||
|
{"password", value},
|
||||||
|
});
|
||||||
|
status_line_ = "Submitting password...";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::FirstName) {
|
||||||
|
pending_first_name_ = value;
|
||||||
|
start_input(InputMode::LastName, "Last name", false);
|
||||||
|
status_line_ = "Enter last name or - for none.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode == InputMode::LastName) {
|
||||||
|
const std::string last_name = value == "-" ? "" : value;
|
||||||
|
td_.send({
|
||||||
|
{"@type", "registerUser"},
|
||||||
|
{"first_name", pending_first_name_},
|
||||||
|
{"last_name", last_name},
|
||||||
|
});
|
||||||
|
pending_first_name_.clear();
|
||||||
|
status_line_ = "Registering account...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::send_message(
|
||||||
|
std::int64_t chat_id,
|
||||||
|
const std::string& text,
|
||||||
|
std::optional<std::int64_t> reply_to_message_id) {
|
||||||
|
json formatted_text = markdown_formatted_text(td_, text);
|
||||||
|
|
||||||
|
json request = {
|
||||||
|
{"@type", "sendMessage"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"input_message_content",
|
||||||
|
{
|
||||||
|
{"@type", "inputMessageText"},
|
||||||
|
{"text", formatted_text},
|
||||||
|
{"clear_draft", true},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
if (reply_to_message_id.has_value()) {
|
||||||
|
request["reply_to"] = {
|
||||||
|
{"@type", "inputMessageReplyToMessage"},
|
||||||
|
{"message_id", *reply_to_message_id},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
td_.send(request);
|
||||||
|
status_line_ = "Message queued.";
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::forward_message(
|
||||||
|
std::int64_t source_chat_id,
|
||||||
|
const std::vector<std::int64_t>& message_ids,
|
||||||
|
std::int64_t target_chat_id) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "forwardMessages"},
|
||||||
|
{"chat_id", target_chat_id},
|
||||||
|
{"from_chat_id", source_chat_id},
|
||||||
|
{"message_ids", message_ids},
|
||||||
|
{"send_copy", false},
|
||||||
|
{"remove_caption", false},
|
||||||
|
});
|
||||||
|
status_line_ = message_ids.size() == 1 ? "Forwarding message..." : "Forwarding messages...";
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::send_photo_message(
|
||||||
|
std::int64_t chat_id,
|
||||||
|
const std::string& photo_path,
|
||||||
|
const std::string& caption,
|
||||||
|
std::optional<std::int64_t> reply_to_message_id) {
|
||||||
|
json request = {
|
||||||
|
{"@type", "sendMessage"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"input_message_content",
|
||||||
|
{
|
||||||
|
{"@type", "inputMessagePhoto"},
|
||||||
|
{"photo", {{"@type", "inputFileLocal"}, {"path", photo_path}}},
|
||||||
|
{"thumbnail", nullptr},
|
||||||
|
{"video", nullptr},
|
||||||
|
{"added_sticker_file_ids", json::array()},
|
||||||
|
{"width", 0},
|
||||||
|
{"height", 0},
|
||||||
|
{"caption", markdown_formatted_text(td_, caption)},
|
||||||
|
{"show_caption_above_media", false},
|
||||||
|
{"self_destruct_type", nullptr},
|
||||||
|
{"has_spoiler", false},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
if (reply_to_message_id.has_value()) {
|
||||||
|
request["reply_to"] = {
|
||||||
|
{"@type", "inputMessageReplyToMessage"},
|
||||||
|
{"message_id", *reply_to_message_id},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
td_.send(request);
|
||||||
|
status_line_ = "Photo queued.";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::preview_clipboard_photo_message(
|
||||||
|
std::int64_t chat_id,
|
||||||
|
const std::string& caption,
|
||||||
|
std::optional<std::int64_t> reply_to_message_id) {
|
||||||
|
const auto clipboard_type = detect_clipboard_image_type();
|
||||||
|
if (!clipboard_type.has_value()) {
|
||||||
|
status_line_ = "Clipboard doesn't contain an image, or no clipboard tool is available.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::filesystem::path clipboard_dir = files_dir_ / "clipboard";
|
||||||
|
try {
|
||||||
|
std::filesystem::create_directories(clipboard_dir);
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
status_line_ = std::string("Failed to prepare clipboard cache: ") + error.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path image_path;
|
||||||
|
for (std::uint64_t index = 0; index < 1024; ++index) {
|
||||||
|
const std::filesystem::path candidate =
|
||||||
|
clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" + std::to_string(index) +
|
||||||
|
clipboard_type->extension);
|
||||||
|
if (!std::filesystem::exists(candidate)) {
|
||||||
|
image_path = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (image_path.empty()) {
|
||||||
|
status_line_ = "Failed to allocate a clipboard image path.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string command = clipboard_capture_command(clipboard_type->mime, image_path.string());
|
||||||
|
if (command.empty() || std::system(command.c_str()) != 0 || !std::filesystem::exists(image_path)) {
|
||||||
|
status_line_ = "Failed to read image data from the clipboard.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (std::filesystem::file_size(image_path) == 0) {
|
||||||
|
std::filesystem::remove(image_path);
|
||||||
|
status_line_ = "Clipboard image is empty.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
status_line_ = "Clipboard image couldn't be validated.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AttachmentInfo attachment;
|
||||||
|
attachment.type = AttachmentType::Photo;
|
||||||
|
attachment.name = image_path.filename().string();
|
||||||
|
attachment.size_bytes = static_cast<std::int64_t>(std::filesystem::file_size(image_path));
|
||||||
|
attachment.local_path = image_path.string();
|
||||||
|
attachment.is_downloaded = true;
|
||||||
|
|
||||||
|
attachment_viewer_attachment_ = attachment;
|
||||||
|
attachment_viewer_title_ = "Clipboard photo: " + attachment.name;
|
||||||
|
attachment_viewer_animation_frames_.clear();
|
||||||
|
attachment_viewer_is_animated_ = false;
|
||||||
|
attachment_viewer_frame_index_ = 0;
|
||||||
|
attachment_viewer_scroll_ = 0;
|
||||||
|
attachment_preview_signature_.clear();
|
||||||
|
attachment_viewer_send_on_enter_ = true;
|
||||||
|
attachment_viewer_send_chat_id_ = chat_id;
|
||||||
|
attachment_viewer_send_reply_to_message_id_ = reply_to_message_id;
|
||||||
|
attachment_viewer_send_caption_ = caption;
|
||||||
|
refresh_attachment_viewer_content(attachment);
|
||||||
|
attachment_viewer_open_ = true;
|
||||||
|
status_line_ = caption.empty()
|
||||||
|
? "Clipboard image preview. Press Enter to send."
|
||||||
|
: "Clipboard image preview with caption. Press Enter to send.";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<std::vector<std::int64_t>> App::parse_forward_command(const std::string& value) const {
|
||||||
|
const auto remainder = strip_forward_prefix(value);
|
||||||
|
if (!remainder.has_value()) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto chat_id = open_chat_id();
|
||||||
|
if (!chat_id.has_value()) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
const auto chat_it = chats_.find(*chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChatInfo& chat = chat_it->second;
|
||||||
|
if (remainder->empty()) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
auto resolve_message_ref = [&](std::int64_t ref) -> std::int64_t {
|
||||||
|
if (ref > 0 && ref <= static_cast<std::int64_t>(chat.messages.size())) {
|
||||||
|
return chat.messages[static_cast<std::size_t>(ref - 1)].id;
|
||||||
|
}
|
||||||
|
for (const auto& message : chat.messages) {
|
||||||
|
if (message.id == ref) {
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::string normalized = *remainder;
|
||||||
|
for (char& ch : normalized) {
|
||||||
|
if (ch == ',') {
|
||||||
|
ch = ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::set<std::int64_t> unique_message_ids;
|
||||||
|
std::stringstream stream(normalized);
|
||||||
|
std::string token;
|
||||||
|
while (stream >> token) {
|
||||||
|
const auto dash = token.find('-');
|
||||||
|
if (dash != std::string::npos) {
|
||||||
|
const std::string start_text = token.substr(0, dash);
|
||||||
|
const std::string end_text = token.substr(dash + 1);
|
||||||
|
if (!is_decimal_number(start_text) || !is_decimal_number(end_text)) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
std::int64_t start_value = 0;
|
||||||
|
std::int64_t end_value = 0;
|
||||||
|
try {
|
||||||
|
start_value = std::stoll(start_text);
|
||||||
|
end_value = std::stoll(end_text);
|
||||||
|
} catch (...) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
if (start_value > end_value) {
|
||||||
|
std::swap(start_value, end_value);
|
||||||
|
}
|
||||||
|
for (std::int64_t ref = start_value; ref <= end_value; ++ref) {
|
||||||
|
const std::int64_t message_id = resolve_message_ref(ref);
|
||||||
|
if (message_id == 0) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
unique_message_ids.insert(message_id);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_decimal_number(token)) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
std::int64_t parsed_value = 0;
|
||||||
|
try {
|
||||||
|
parsed_value = std::stoll(token);
|
||||||
|
} catch (...) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
const std::int64_t message_id = resolve_message_ref(parsed_value);
|
||||||
|
if (message_id == 0) {
|
||||||
|
return std::vector<std::int64_t>{};
|
||||||
|
}
|
||||||
|
unique_message_ids.insert(message_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return std::vector<std::int64_t>(unique_message_ids.begin(), unique_message_ids.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
269
src/app_chats.cpp
Normal file
269
src/app_chats.cpp
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string join_with_separator_local(const std::vector<std::string>& parts, const char* separator) {
|
||||||
|
std::string joined;
|
||||||
|
for (const auto& part : parts) {
|
||||||
|
if (part.empty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!joined.empty()) {
|
||||||
|
joined += separator;
|
||||||
|
}
|
||||||
|
joined += part;
|
||||||
|
}
|
||||||
|
return joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_user_status(const json& status) {
|
||||||
|
const std::string type = safe_string(status, "@type");
|
||||||
|
if (type == "userStatusOnline") {
|
||||||
|
return "online";
|
||||||
|
}
|
||||||
|
if (type == "userStatusOffline") {
|
||||||
|
const std::int32_t was_online = safe_i32(status, "was_online");
|
||||||
|
return was_online > 0 ? ("last seen " + format_datetime(was_online)) : "offline";
|
||||||
|
}
|
||||||
|
if (type == "userStatusRecently") {
|
||||||
|
return "last seen recently";
|
||||||
|
}
|
||||||
|
if (type == "userStatusLastWeek") {
|
||||||
|
return "last seen within a week";
|
||||||
|
}
|
||||||
|
if (type == "userStatusLastMonth") {
|
||||||
|
return "last seen within a month";
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string primary_username(const json& object) {
|
||||||
|
const json usernames = object.value("usernames", json::object());
|
||||||
|
const json active_usernames = usernames.value("active_usernames", json::array());
|
||||||
|
if (active_usernames.is_array()) {
|
||||||
|
for (const auto& username : active_usernames) {
|
||||||
|
if (username.is_string()) {
|
||||||
|
const std::string value = username.get<std::string>();
|
||||||
|
if (!value.empty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return safe_string(object, "username");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void App::update_user_status(std::int64_t user_id, const json& status) {
|
||||||
|
UserInfo& info = users_[user_id];
|
||||||
|
info.id = user_id;
|
||||||
|
info.status = format_user_status(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::upsert_user(const json& user) {
|
||||||
|
if (user.is_null()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
UserInfo& info = users_[safe_i64(user, "id")];
|
||||||
|
info.id = safe_i64(user, "id");
|
||||||
|
info.first_name = safe_string(user, "first_name");
|
||||||
|
info.last_name = safe_string(user, "last_name");
|
||||||
|
info.username = primary_username(user);
|
||||||
|
info.status = format_user_status(user.value("status", json::object()));
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::upsert_basic_group(const json& basic_group) {
|
||||||
|
if (basic_group.is_null()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::int64_t basic_group_id = safe_i64(basic_group, "id");
|
||||||
|
const std::int32_t member_count = safe_i32(basic_group, "member_count");
|
||||||
|
for (auto& [chat_id, chat] : chats_) {
|
||||||
|
if (chat.basic_group_id != basic_group_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chat.id = chat_id;
|
||||||
|
chat.username.clear();
|
||||||
|
chat.member_count = member_count;
|
||||||
|
chat.has_member_count = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::upsert_supergroup(const json& supergroup) {
|
||||||
|
if (supergroup.is_null()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const std::int64_t supergroup_id = safe_i64(supergroup, "id");
|
||||||
|
const std::int32_t member_count = safe_i32(supergroup, "member_count");
|
||||||
|
const bool is_channel = supergroup.value("is_channel", false);
|
||||||
|
const std::string username = primary_username(supergroup);
|
||||||
|
for (auto& [chat_id, chat] : chats_) {
|
||||||
|
if (chat.supergroup_id != supergroup_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
chat.id = chat_id;
|
||||||
|
chat.is_channel = is_channel;
|
||||||
|
chat.username = username;
|
||||||
|
chat.member_count = member_count;
|
||||||
|
chat.has_member_count = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::upsert_chat(const json& chat_object) {
|
||||||
|
if (chat_object.is_null()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::int64_t chat_id = safe_i64(chat_object, "id");
|
||||||
|
ChatInfo& chat = chats_[chat_id];
|
||||||
|
chat.id = chat_id;
|
||||||
|
if (chat_object.contains("title")) {
|
||||||
|
chat.title = safe_string(chat_object, "title");
|
||||||
|
}
|
||||||
|
const json type = chat_object.value("type", json::object());
|
||||||
|
const std::string type_name = safe_string(type, "@type");
|
||||||
|
const std::int64_t previous_private_user_id = chat.private_user_id;
|
||||||
|
const std::int64_t previous_basic_group_id = chat.basic_group_id;
|
||||||
|
const std::int64_t previous_supergroup_id = chat.supergroup_id;
|
||||||
|
chat.private_user_id = 0;
|
||||||
|
chat.basic_group_id = 0;
|
||||||
|
chat.supergroup_id = 0;
|
||||||
|
chat.is_channel = false;
|
||||||
|
if (type_name == "chatTypePrivate" || type_name == "chatTypeSecret") {
|
||||||
|
chat.private_user_id = safe_i64(type, "user_id");
|
||||||
|
chat.username.clear();
|
||||||
|
chat.has_member_count = false;
|
||||||
|
chat.has_online_member_count = false;
|
||||||
|
chat.member_count = 0;
|
||||||
|
chat.online_member_count = 0;
|
||||||
|
} else if (type_name == "chatTypeBasicGroup") {
|
||||||
|
chat.basic_group_id = safe_i64(type, "basic_group_id");
|
||||||
|
chat.username.clear();
|
||||||
|
} else if (type_name == "chatTypeSupergroup") {
|
||||||
|
chat.supergroup_id = safe_i64(type, "supergroup_id");
|
||||||
|
chat.is_channel = type.value("is_channel", false);
|
||||||
|
if (chat.supergroup_id != previous_supergroup_id) {
|
||||||
|
chat.username.clear();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chat.username.clear();
|
||||||
|
}
|
||||||
|
if (chat.private_user_id != previous_private_user_id ||
|
||||||
|
chat.basic_group_id != previous_basic_group_id ||
|
||||||
|
chat.supergroup_id != previous_supergroup_id) {
|
||||||
|
chat.details_requested = false;
|
||||||
|
}
|
||||||
|
chat.unread_count = safe_i32(chat_object, "unread_count");
|
||||||
|
chat.last_read_inbox_message_id = safe_i64(chat_object, "last_read_inbox_message_id");
|
||||||
|
chat.last_read_outbox_message_id = safe_i64(chat_object, "last_read_outbox_message_id");
|
||||||
|
if (chat_object.contains("last_message")) {
|
||||||
|
chat.last_message_preview = preview_message(chat_object.at("last_message"));
|
||||||
|
}
|
||||||
|
if (chat_object.contains("positions") && chat_object.at("positions").is_array()) {
|
||||||
|
chat.in_main_list = false;
|
||||||
|
chat.main_order = 0;
|
||||||
|
for (const auto& position : chat_object.at("positions")) {
|
||||||
|
apply_chat_position(chat, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (open_chat_id_ == chat.id) {
|
||||||
|
request_chat_details(chat.id);
|
||||||
|
}
|
||||||
|
resort_chats();
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::apply_chat_position(ChatInfo& chat, const json& position) {
|
||||||
|
if (!position.is_object()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json chat_list = position.value("list", json::object());
|
||||||
|
if (safe_string(chat_list, "@type") != "chatListMain") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chat.in_main_list = true;
|
||||||
|
chat.main_order = safe_i64(position, "order");
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::resort_chats() {
|
||||||
|
if (sorted_chat_ids_.empty()) {
|
||||||
|
for (const auto& [chat_id, chat] : chats_) {
|
||||||
|
if (chat.in_main_list) {
|
||||||
|
sorted_chat_ids_.push_back(chat_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const auto& [chat_id, chat] : chats_) {
|
||||||
|
if (!chat.in_main_list) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (std::find(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), chat_id) == sorted_chat_ids_.end()) {
|
||||||
|
sorted_chat_ids_.push_back(chat_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::stable_sort(sorted_chat_ids_.begin(), sorted_chat_ids_.end(),
|
||||||
|
[&](std::int64_t lhs, std::int64_t rhs) {
|
||||||
|
const auto left_it = chats_.find(lhs);
|
||||||
|
const auto right_it = chats_.find(rhs);
|
||||||
|
const bool left_has_order = left_it != chats_.end() && left_it->second.in_main_list && left_it->second.main_order > 0;
|
||||||
|
const bool right_has_order = right_it != chats_.end() && right_it->second.in_main_list && right_it->second.main_order > 0;
|
||||||
|
if (left_has_order != right_has_order) {
|
||||||
|
return left_has_order;
|
||||||
|
}
|
||||||
|
if (left_has_order && right_has_order && left_it->second.main_order != right_it->second.main_order) {
|
||||||
|
return left_it->second.main_order > right_it->second.main_order;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected_chat_index_ >= static_cast<int>(sorted_chat_ids_.size())) {
|
||||||
|
selected_chat_index_ = std::max(0, static_cast<int>(sorted_chat_ids_.size()) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string App::format_open_chat_header(const ChatInfo& chat) const {
|
||||||
|
std::vector<std::string> parts;
|
||||||
|
if (!chat.title.empty()) {
|
||||||
|
parts.push_back(chat.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.private_user_id != 0) {
|
||||||
|
const auto user_it = users_.find(chat.private_user_id);
|
||||||
|
if (user_it != users_.end()) {
|
||||||
|
if (!user_it->second.username.empty()) {
|
||||||
|
parts.push_back("@" + user_it->second.username);
|
||||||
|
}
|
||||||
|
const std::string status = user_status_label(chat.private_user_id);
|
||||||
|
if (!status.empty()) {
|
||||||
|
parts.push_back(status);
|
||||||
|
}
|
||||||
|
parts.push_back("id:" + std::to_string(user_it->second.id));
|
||||||
|
} else {
|
||||||
|
parts.push_back("id:" + std::to_string(chat.private_user_id));
|
||||||
|
}
|
||||||
|
return join_with_separator_local(parts, " | ");
|
||||||
|
}
|
||||||
|
if (!chat.username.empty()) {
|
||||||
|
parts.push_back("@" + chat.username);
|
||||||
|
}
|
||||||
|
if (chat.has_member_count) {
|
||||||
|
parts.push_back(
|
||||||
|
std::to_string(chat.member_count) + " " + (chat.is_channel ? "subscribers" : "members"));
|
||||||
|
}
|
||||||
|
if (!chat.is_channel && chat.has_online_member_count) {
|
||||||
|
parts.push_back(std::to_string(chat.online_member_count) + " online");
|
||||||
|
}
|
||||||
|
parts.push_back("id:" + std::to_string(chat.id));
|
||||||
|
return join_with_separator_local(parts, " | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
545
src/app_state.cpp
Normal file
545
src/app_state.cpp
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <set>
|
||||||
|
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string format_download_progress(std::int64_t downloaded_size, std::int64_t size_bytes, bool is_downloaded) {
|
||||||
|
if (is_downloaded) {
|
||||||
|
return "100%";
|
||||||
|
}
|
||||||
|
if (size_bytes > 0) {
|
||||||
|
const auto downloaded = std::min(downloaded_size, size_bytes);
|
||||||
|
return std::to_string(static_cast<int>((downloaded * 100) / size_bytes)) + "% " +
|
||||||
|
format_file_size(downloaded) + "/" + format_file_size(size_bytes);
|
||||||
|
}
|
||||||
|
return format_file_size(downloaded_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool App::process_updates() {
|
||||||
|
bool changed = false;
|
||||||
|
while (true) {
|
||||||
|
auto update = td_.receive(0.0);
|
||||||
|
if (!update.has_value()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
handle_td_object(*update);
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::handle_td_object(const json& object) {
|
||||||
|
const std::string type = safe_string(object, "@type");
|
||||||
|
if (type == "updateAuthorizationState") {
|
||||||
|
authorization_state_ = object.value("authorization_state", json::object());
|
||||||
|
handle_authorization_state();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateConnectionState") {
|
||||||
|
status_line_ = "Connection: " + safe_string(object.at("state"), "@type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatOnlineMemberCount") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
chat.online_member_count = safe_i32(object, "online_member_count");
|
||||||
|
chat.has_online_member_count = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateUser") {
|
||||||
|
upsert_user(object.value("user", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateUserStatus") {
|
||||||
|
update_user_status(safe_i64(object, "user_id"), object.value("status", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateBasicGroup" || type == "basicGroup") {
|
||||||
|
upsert_basic_group(type == "basicGroup" ? object : object.value("basic_group", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateSupergroup" || type == "supergroup") {
|
||||||
|
upsert_supergroup(type == "supergroup" ? object : object.value("supergroup", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateNewChat") {
|
||||||
|
upsert_chat(object.value("chat", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatTitle") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
chat.title = safe_string(object, "title");
|
||||||
|
resort_chats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatPosition") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
apply_chat_position(chat, object.value("position", json::object()));
|
||||||
|
resort_chats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatLastMessage") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
chat.last_message_preview = preview_message(object.value("last_message", json::object()));
|
||||||
|
if (object.contains("positions") && object.at("positions").is_array()) {
|
||||||
|
chat.in_main_list = false;
|
||||||
|
chat.main_order = 0;
|
||||||
|
for (const auto& position : object.at("positions")) {
|
||||||
|
apply_chat_position(chat, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resort_chats();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatReadInbox") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
chat.unread_count = safe_i32(object, "unread_count");
|
||||||
|
chat.last_read_inbox_message_id = safe_i64(object, "last_read_inbox_message_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateChatReadOutbox") {
|
||||||
|
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
|
||||||
|
chat.id = safe_i64(object, "chat_id");
|
||||||
|
chat.last_read_outbox_message_id = safe_i64(object, "last_read_outbox_message_id");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateNewMessage") {
|
||||||
|
const json message = object.value("message", json::object());
|
||||||
|
const std::int64_t chat_id = safe_i64(message, "chat_id");
|
||||||
|
if (chat_id == open_chat_id_ && message_scroll_ > 0) {
|
||||||
|
++message_scroll_;
|
||||||
|
}
|
||||||
|
const MessageInfo parsed = parse_message(message);
|
||||||
|
append_message(chat_id, parsed);
|
||||||
|
chats_[chat_id].last_message_preview = preview_message(message);
|
||||||
|
if (chat_id == open_chat_id_ && !parsed.is_outgoing) {
|
||||||
|
mark_message_as_read(chat_id, parsed.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateMessageContent") {
|
||||||
|
const std::int64_t chat_id = safe_i64(object, "chat_id");
|
||||||
|
const std::int64_t message_id = safe_i64(object, "message_id");
|
||||||
|
auto chat_it = chats_.find(chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (auto& message : chat_it->second.messages) {
|
||||||
|
if (message.id == message_id) {
|
||||||
|
const json content = object.value("new_content", json::object());
|
||||||
|
message.text = content_to_text(content, true);
|
||||||
|
if (const auto attachment = parse_attachment(content); attachment.has_value()) {
|
||||||
|
message.has_attachment = true;
|
||||||
|
message.attachment = *attachment;
|
||||||
|
} else {
|
||||||
|
message.has_attachment = false;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateDeleteMessages") {
|
||||||
|
const std::int64_t chat_id = safe_i64(object, "chat_id");
|
||||||
|
auto chat_it = chats_.find(chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::set<std::int64_t> deleted;
|
||||||
|
for (const auto& id : object.value("message_ids", json::array())) {
|
||||||
|
if (id.is_number_integer()) {
|
||||||
|
deleted.insert(id.get<std::int64_t>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::vector<MessageInfo> kept;
|
||||||
|
kept.reserve(chat_it->second.messages.size());
|
||||||
|
for (const auto& message : chat_it->second.messages) {
|
||||||
|
if (deleted.find(message.id) == deleted.end()) {
|
||||||
|
kept.push_back(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chat_it->second.messages = std::move(kept);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateFile") {
|
||||||
|
update_attachment_file(object.value("file", json::object()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateOption" || type == "ok" || type == "userFullInfo" ||
|
||||||
|
type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" ||
|
||||||
|
type == "updateUnreadChatCount") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "error") {
|
||||||
|
const std::string extra = safe_string(object, "@extra");
|
||||||
|
const std::string message = safe_string(object, "message");
|
||||||
|
if (extra.rfind("history:", 0) == 0) {
|
||||||
|
const auto chat_id = static_cast<std::int64_t>(std::stoll(extra.substr(8)));
|
||||||
|
chats_[chat_id].history_loading = false;
|
||||||
|
status_line_ = "History unavailable: " + message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status_line_ = "TDLib error: " + message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "user") {
|
||||||
|
upsert_user(object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "chat") {
|
||||||
|
upsert_chat(object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "chats") {
|
||||||
|
const std::string extra = safe_string(object, "@extra");
|
||||||
|
if (extra == "main_chats") {
|
||||||
|
sync_chat_ids_from_response(object);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "messages") {
|
||||||
|
const std::string extra = safe_string(object, "@extra");
|
||||||
|
if (extra.rfind("history:", 0) == 0) {
|
||||||
|
const auto chat_id = static_cast<std::int64_t>(std::stoll(extra.substr(8)));
|
||||||
|
merge_history(chat_id, object.value("messages", json::array()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (type == "updateMessageSendSucceeded") {
|
||||||
|
const json message = object.value("message", json::object());
|
||||||
|
const std::int64_t chat_id = safe_i64(message, "chat_id");
|
||||||
|
remove_message(chat_id, safe_i64(object, "old_message_id"));
|
||||||
|
append_message(chat_id, parse_message(message));
|
||||||
|
chats_[chat_id].last_message_preview = preview_message(message);
|
||||||
|
status_line_ = "Message sent.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string extra = safe_string(object, "@extra");
|
||||||
|
if (extra == "getMe" && object.is_object()) {
|
||||||
|
my_user_id_ = safe_i64(object, "id");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::request_more_chats() {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getChats"},
|
||||||
|
{"chat_list", {{"@type", "chatListMain"}}},
|
||||||
|
{"limit", kChatPageSize},
|
||||||
|
{"@extra", "main_chats"},
|
||||||
|
});
|
||||||
|
status_line_ = "Loading chats...";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::request_chat_history(std::int64_t chat_id, bool force) {
|
||||||
|
ChatInfo& chat = chats_[chat_id];
|
||||||
|
chat.id = chat_id;
|
||||||
|
if (chat.history_loading && !force) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
chat.history_loading = true;
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getChatHistory"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"from_message_id", 0},
|
||||||
|
{"offset", 0},
|
||||||
|
{"limit", kHistoryBatchSize},
|
||||||
|
{"only_local", false},
|
||||||
|
{"@extra", "history:" + std::to_string(chat_id)},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool App::request_open_chat_history(bool force) {
|
||||||
|
const auto chat_id = open_chat_id();
|
||||||
|
if (!chat_id.has_value()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
request_chat_details(*chat_id);
|
||||||
|
const bool requested = request_chat_history(*chat_id, force);
|
||||||
|
if (requested) {
|
||||||
|
status_line_ = force ? "Reloading chat history..." : "Loading chat history...";
|
||||||
|
}
|
||||||
|
return requested;
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::mark_chat_messages_as_read(std::int64_t chat_id) {
|
||||||
|
auto chat_it = chats_.find(chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::int64_t> unread_message_ids;
|
||||||
|
unread_message_ids.reserve(chat_it->second.messages.size());
|
||||||
|
for (const auto& message : chat_it->second.messages) {
|
||||||
|
if (message.is_outgoing || message.id == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (message.id <= chat_it->second.last_read_inbox_message_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
unread_message_ids.push_back(message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!unread_message_ids.empty()) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "viewMessages"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"message_ids", unread_message_ids},
|
||||||
|
{"source", {{"@type", "messageSourceChatHistory"}}},
|
||||||
|
{"force_read", true},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
td_.send({
|
||||||
|
{"@type", "toggleChatIsMarkedAsUnread"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"is_marked_as_unread", false},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::mark_message_as_read(std::int64_t chat_id, std::int64_t message_id) {
|
||||||
|
if (chat_id == 0 || message_id == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
td_.send({
|
||||||
|
{"@type", "viewMessages"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
{"message_ids", json::array({message_id})},
|
||||||
|
{"source", {{"@type", "messageSourceChatHistory"}}},
|
||||||
|
{"force_read", true},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::set_open_chat(std::int64_t chat_id) {
|
||||||
|
if (chat_id == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool changed_chat = open_chat_id_ != chat_id;
|
||||||
|
ChatInfo& chat = chats_[chat_id];
|
||||||
|
chat.id = chat_id;
|
||||||
|
if (tdlib_open_chat_id_ != 0 && tdlib_open_chat_id_ != chat_id) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "closeChat"},
|
||||||
|
{"chat_id", tdlib_open_chat_id_},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
open_chat_id_ = chat_id;
|
||||||
|
message_scroll_ = 0;
|
||||||
|
|
||||||
|
if (tdlib_open_chat_id_ != chat_id) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "openChat"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
});
|
||||||
|
tdlib_open_chat_id_ = chat_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
mark_chat_messages_as_read(chat_id);
|
||||||
|
|
||||||
|
if (changed_chat) {
|
||||||
|
request_chat_details(chat_id);
|
||||||
|
const bool should_request_history =
|
||||||
|
auto_reload_chat_history_ || !chat.history_loaded || chat.messages.empty();
|
||||||
|
const bool requested = should_request_history &&
|
||||||
|
request_chat_history(chat_id, auto_reload_chat_history_);
|
||||||
|
if (requested) {
|
||||||
|
status_line_ = auto_reload_chat_history_ && chat.history_loaded
|
||||||
|
? "Reloading chat history..."
|
||||||
|
: "Loading chat history...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::sync_chat_ids_from_response(const json& response) {
|
||||||
|
if (!response.contains("chat_ids") || !response.at("chat_ids").is_array()) {
|
||||||
|
status_line_ = "Chat list response missing chat ids.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::int64_t> chat_ids;
|
||||||
|
for (const auto& entry : response.at("chat_ids")) {
|
||||||
|
if (!entry.is_number_integer()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const auto chat_id = entry.get<std::int64_t>();
|
||||||
|
chat_ids.push_back(chat_id);
|
||||||
|
if (chats_.find(chat_id) == chats_.end()) {
|
||||||
|
td_.send({
|
||||||
|
{"@type", "getChat"},
|
||||||
|
{"chat_id", chat_id},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chat_ids.empty()) {
|
||||||
|
sorted_chat_ids_ = std::move(chat_ids);
|
||||||
|
if (selected_chat_index_ >= static_cast<int>(sorted_chat_ids_.size())) {
|
||||||
|
selected_chat_index_ = std::max(0, static_cast<int>(sorted_chat_ids_.size()) - 1);
|
||||||
|
}
|
||||||
|
if (!open_chat_id().has_value()) {
|
||||||
|
const auto chat_id = highlighted_chat_id();
|
||||||
|
if (chat_id.has_value()) {
|
||||||
|
set_open_chat(*chat_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request_open_chat_history(false);
|
||||||
|
status_line_ = "Chats loaded.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status_line_ = "No chats loaded.";
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::append_message(std::int64_t chat_id, MessageInfo message) {
|
||||||
|
ChatInfo& chat = chats_[chat_id];
|
||||||
|
chat.id = chat_id;
|
||||||
|
auto existing = std::find_if(chat.messages.begin(), chat.messages.end(),
|
||||||
|
[&](const MessageInfo& item) { return item.id == message.id; });
|
||||||
|
if (existing != chat.messages.end()) {
|
||||||
|
*existing = std::move(message);
|
||||||
|
} else {
|
||||||
|
chat.messages.push_back(std::move(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::sort(chat.messages.begin(), chat.messages.end(),
|
||||||
|
[](const MessageInfo& lhs, const MessageInfo& rhs) {
|
||||||
|
if (lhs.date != rhs.date) {
|
||||||
|
return lhs.date < rhs.date;
|
||||||
|
}
|
||||||
|
return lhs.id < rhs.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kMaxMessagesPerChat > 0 && chat.messages.size() > kMaxMessagesPerChat) {
|
||||||
|
chat.messages.erase(
|
||||||
|
chat.messages.begin(),
|
||||||
|
chat.messages.begin() +
|
||||||
|
static_cast<std::ptrdiff_t>(chat.messages.size() - kMaxMessagesPerChat));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::remove_message(std::int64_t chat_id, std::int64_t message_id) {
|
||||||
|
if (message_id == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto chat_it = chats_.find(chat_id);
|
||||||
|
if (chat_it == chats_.end()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& messages = chat_it->second.messages;
|
||||||
|
messages.erase(
|
||||||
|
std::remove_if(
|
||||||
|
messages.begin(),
|
||||||
|
messages.end(),
|
||||||
|
[&](const MessageInfo& item) { return item.id == message_id; }),
|
||||||
|
messages.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::merge_history(std::int64_t chat_id, const json& messages) {
|
||||||
|
ChatInfo& chat = chats_[chat_id];
|
||||||
|
chat.history_loading = false;
|
||||||
|
chat.history_loaded = true;
|
||||||
|
for (const auto& message : messages) {
|
||||||
|
append_message(chat_id, parse_message(message));
|
||||||
|
}
|
||||||
|
if (chat_id == open_chat_id_) {
|
||||||
|
mark_chat_messages_as_read(chat_id);
|
||||||
|
}
|
||||||
|
status_line_ = "History loaded.";
|
||||||
|
}
|
||||||
|
|
||||||
|
void App::update_attachment_file(const json& file) {
|
||||||
|
const std::int32_t file_id = safe_i32(file, "id");
|
||||||
|
if (file_id == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::int64_t size_bytes = std::max(safe_i64(file, "size"), safe_i64(file, "expected_size"));
|
||||||
|
const json local = file.value("local", json::object());
|
||||||
|
const std::string local_path = safe_string(local, "path");
|
||||||
|
const std::int64_t downloaded_size = safe_i64(local, "downloaded_size");
|
||||||
|
const bool is_downloading_active = local.value("is_downloading_active", false);
|
||||||
|
const bool is_downloaded = local.value("is_downloading_completed", false);
|
||||||
|
const bool can_be_downloaded = local.value("can_be_downloaded", false);
|
||||||
|
const bool can_be_deleted = local.value("can_be_deleted", false);
|
||||||
|
|
||||||
|
for (auto& [chat_id, chat] : chats_) {
|
||||||
|
(void) chat_id;
|
||||||
|
for (auto& message : chat.messages) {
|
||||||
|
if (!message.has_attachment || message.attachment.file_id != file_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (size_bytes > 0) {
|
||||||
|
message.attachment.size_bytes = size_bytes;
|
||||||
|
}
|
||||||
|
message.attachment.downloaded_size = downloaded_size;
|
||||||
|
message.attachment.local_path = local_path;
|
||||||
|
message.attachment.is_downloading_active = is_downloading_active;
|
||||||
|
message.attachment.is_downloaded = is_downloaded;
|
||||||
|
message.attachment.can_be_downloaded = can_be_downloaded;
|
||||||
|
message.attachment.can_be_deleted = can_be_deleted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending_attachment_open_.has_value() && pending_attachment_open_->file_id == file_id) {
|
||||||
|
pending_attachment_open_->size_bytes = size_bytes > 0 ? size_bytes : pending_attachment_open_->size_bytes;
|
||||||
|
pending_attachment_open_->downloaded_size = downloaded_size;
|
||||||
|
pending_attachment_open_->local_path = local_path;
|
||||||
|
pending_attachment_open_->is_downloading_active = is_downloading_active;
|
||||||
|
pending_attachment_open_->is_downloaded = is_downloaded;
|
||||||
|
pending_attachment_open_->can_be_downloaded = can_be_downloaded;
|
||||||
|
pending_attachment_open_->can_be_deleted = can_be_deleted;
|
||||||
|
if (!is_downloaded && is_downloading_active) {
|
||||||
|
status_line_ = "Downloading attachment to open " +
|
||||||
|
format_download_progress(
|
||||||
|
pending_attachment_open_->downloaded_size,
|
||||||
|
pending_attachment_open_->size_bytes,
|
||||||
|
pending_attachment_open_->is_downloaded);
|
||||||
|
}
|
||||||
|
if (is_downloaded && !local_path.empty()) {
|
||||||
|
open_attachment(*pending_attachment_open_);
|
||||||
|
pending_attachment_open_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pending_attachment_download_.has_value() && pending_attachment_download_->file_id == file_id) {
|
||||||
|
pending_attachment_download_->size_bytes =
|
||||||
|
size_bytes > 0 ? size_bytes : pending_attachment_download_->size_bytes;
|
||||||
|
pending_attachment_download_->downloaded_size = downloaded_size;
|
||||||
|
pending_attachment_download_->local_path = local_path;
|
||||||
|
pending_attachment_download_->is_downloading_active = is_downloading_active;
|
||||||
|
pending_attachment_download_->is_downloaded = is_downloaded;
|
||||||
|
pending_attachment_download_->can_be_downloaded = can_be_downloaded;
|
||||||
|
pending_attachment_download_->can_be_deleted = can_be_deleted;
|
||||||
|
if (!is_downloaded && is_downloading_active) {
|
||||||
|
status_line_ = "Downloading attachment to ~/Downloads " +
|
||||||
|
format_download_progress(
|
||||||
|
pending_attachment_download_->downloaded_size,
|
||||||
|
pending_attachment_download_->size_bytes,
|
||||||
|
pending_attachment_download_->is_downloaded);
|
||||||
|
}
|
||||||
|
if (is_downloaded && !local_path.empty()) {
|
||||||
|
export_attachment_to_downloads(*pending_attachment_download_);
|
||||||
|
pending_attachment_download_.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
9
src/json.h
Normal file
9
src/json.h
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
using json = nlohmann::json;
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
17
src/main.cpp
Normal file
17
src/main.cpp
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#include <cstdio>
|
||||||
|
#include <exception>
|
||||||
|
|
||||||
|
#include <curses.h>
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
int main() {
|
||||||
|
try {
|
||||||
|
telegram_tui::App app;
|
||||||
|
return app.run();
|
||||||
|
} catch (const std::exception& error) {
|
||||||
|
endwin();
|
||||||
|
std::fprintf(stderr, "fatal: %s\n", error.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/models.cpp
Normal file
25
src/models.cpp
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#include "models.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
std::string UserInfo::display_name() const {
|
||||||
|
std::string name;
|
||||||
|
if (!first_name.empty()) {
|
||||||
|
name += first_name;
|
||||||
|
}
|
||||||
|
if (!last_name.empty()) {
|
||||||
|
if (!name.empty()) {
|
||||||
|
name += " ";
|
||||||
|
}
|
||||||
|
name += last_name;
|
||||||
|
}
|
||||||
|
if (!name.empty()) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
if (!username.empty()) {
|
||||||
|
return "@" + username;
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
101
src/models.h
Normal file
101
src/models.h
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
constexpr std::size_t kMaxMessagesPerChat = 0;
|
||||||
|
constexpr int kPollTimeoutMs = 50;
|
||||||
|
constexpr int kChatPageSize = 100;
|
||||||
|
constexpr int kHistoryBatchSize = 50;
|
||||||
|
|
||||||
|
struct UserInfo {
|
||||||
|
std::int64_t id = 0;
|
||||||
|
std::string first_name;
|
||||||
|
std::string last_name;
|
||||||
|
std::string username;
|
||||||
|
std::string status;
|
||||||
|
|
||||||
|
[[nodiscard]] std::string display_name() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class AttachmentType {
|
||||||
|
Photo,
|
||||||
|
Video,
|
||||||
|
Document,
|
||||||
|
Audio,
|
||||||
|
Voice,
|
||||||
|
Animation,
|
||||||
|
Sticker,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AttachmentInfo {
|
||||||
|
AttachmentType type = AttachmentType::Photo;
|
||||||
|
std::string name;
|
||||||
|
std::int64_t size_bytes = 0;
|
||||||
|
std::int64_t downloaded_size = 0;
|
||||||
|
std::int32_t file_id = 0;
|
||||||
|
std::string local_path;
|
||||||
|
bool is_downloading_active = false;
|
||||||
|
bool can_be_downloaded = false;
|
||||||
|
bool can_be_deleted = false;
|
||||||
|
bool is_downloaded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MessageInfo {
|
||||||
|
std::int64_t id = 0;
|
||||||
|
std::int32_t date = 0;
|
||||||
|
bool is_outgoing = false;
|
||||||
|
std::int64_t reply_to_message_id = 0;
|
||||||
|
bool has_attachment = false;
|
||||||
|
AttachmentInfo attachment;
|
||||||
|
std::string forward_info;
|
||||||
|
std::string sender;
|
||||||
|
std::string text;
|
||||||
|
std::string via_bot;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ChatInfo {
|
||||||
|
std::int64_t id = 0;
|
||||||
|
std::int64_t private_user_id = 0;
|
||||||
|
std::int64_t basic_group_id = 0;
|
||||||
|
std::int64_t supergroup_id = 0;
|
||||||
|
std::int64_t last_read_inbox_message_id = 0;
|
||||||
|
std::int64_t last_read_outbox_message_id = 0;
|
||||||
|
std::string title = "Loading...";
|
||||||
|
std::string username;
|
||||||
|
std::string last_message_preview;
|
||||||
|
std::int32_t unread_count = 0;
|
||||||
|
std::int32_t member_count = 0;
|
||||||
|
std::int32_t online_member_count = 0;
|
||||||
|
std::int64_t main_order = 0;
|
||||||
|
bool is_channel = false;
|
||||||
|
bool in_main_list = false;
|
||||||
|
bool history_loading = false;
|
||||||
|
bool history_loaded = false;
|
||||||
|
bool details_requested = false;
|
||||||
|
bool has_member_count = false;
|
||||||
|
bool has_online_member_count = false;
|
||||||
|
std::vector<MessageInfo> messages;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class FocusPane {
|
||||||
|
Chats,
|
||||||
|
Messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class InputMode {
|
||||||
|
None,
|
||||||
|
Compose,
|
||||||
|
ApiId,
|
||||||
|
ApiHash,
|
||||||
|
PhoneNumber,
|
||||||
|
AuthCode,
|
||||||
|
Password,
|
||||||
|
FirstName,
|
||||||
|
LastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
66
src/td_client.cpp
Normal file
66
src/td_client.cpp
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
#include "td_client.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <td/telegram/td_json_client.h>
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void configure_tdlib_logging() {
|
||||||
|
const char* stream_request =
|
||||||
|
R"({"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}})";
|
||||||
|
const char* verbosity_request =
|
||||||
|
R"({"@type":"setLogVerbosityLevel","new_verbosity_level":0})";
|
||||||
|
|
||||||
|
td_json_client_execute(nullptr, stream_request);
|
||||||
|
td_json_client_execute(nullptr, verbosity_request);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TdClient::TdClient() {
|
||||||
|
configure_tdlib_logging();
|
||||||
|
client_ = td_json_client_create();
|
||||||
|
}
|
||||||
|
|
||||||
|
TdClient::~TdClient() {
|
||||||
|
if (client_ != nullptr) {
|
||||||
|
td_json_client_destroy(client_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TdClient::send(const json& request) {
|
||||||
|
const std::string payload = request.dump();
|
||||||
|
td_json_client_send(client_, payload.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<json> TdClient::execute(const json& request) {
|
||||||
|
const std::string payload = request.dump();
|
||||||
|
const char* raw = td_json_client_execute(client_, payload.c_str());
|
||||||
|
if (raw == nullptr) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return json::parse(raw);
|
||||||
|
} catch (...) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<json> TdClient::receive(double timeout_seconds) {
|
||||||
|
const char* raw = td_json_client_receive(client_, timeout_seconds);
|
||||||
|
if (raw == nullptr) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return json::parse(raw);
|
||||||
|
} catch (...) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
25
src/td_client.h
Normal file
25
src/td_client.h
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
#include "json.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
class TdClient {
|
||||||
|
public:
|
||||||
|
TdClient();
|
||||||
|
~TdClient();
|
||||||
|
|
||||||
|
TdClient(const TdClient&) = delete;
|
||||||
|
TdClient& operator=(const TdClient&) = delete;
|
||||||
|
|
||||||
|
void send(const json& request);
|
||||||
|
[[nodiscard]] std::optional<json> execute(const json& request);
|
||||||
|
[[nodiscard]] std::optional<json> receive(double timeout_seconds);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void* client_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
366
src/util.cpp
Normal file
366
src/util.cpp
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
#include "util.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <ctime>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <wchar.h>
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::size_t utf8_sequence_size(unsigned char lead) {
|
||||||
|
if ((lead & 0x80U) == 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ((lead & 0xE0U) == 0xC0U) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if ((lead & 0xF0U) == 0xE0U) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if ((lead & 0xF8U) == 0xF0U) {
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_utf8_continuation(unsigned char ch) {
|
||||||
|
return (ch & 0xC0U) == 0x80U;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t decode_utf8_codepoint(const std::string& text, std::size_t offset, std::size_t* size_out = nullptr) {
|
||||||
|
if (offset >= text.size()) {
|
||||||
|
if (size_out != nullptr) {
|
||||||
|
*size_out = 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto lead = static_cast<unsigned char>(text[offset]);
|
||||||
|
std::size_t size = utf8_sequence_size(lead);
|
||||||
|
if (offset + size > text.size()) {
|
||||||
|
size = 1;
|
||||||
|
}
|
||||||
|
for (std::size_t i = 1; i < size; ++i) {
|
||||||
|
if (!is_utf8_continuation(static_cast<unsigned char>(text[offset + i]))) {
|
||||||
|
size = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size_out != nullptr) {
|
||||||
|
*size_out = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size == 1) {
|
||||||
|
return lead;
|
||||||
|
}
|
||||||
|
if (size == 2) {
|
||||||
|
return ((lead & 0x1FU) << 6) |
|
||||||
|
(static_cast<unsigned char>(text[offset + 1]) & 0x3FU);
|
||||||
|
}
|
||||||
|
if (size == 3) {
|
||||||
|
return ((lead & 0x0FU) << 12) |
|
||||||
|
((static_cast<unsigned char>(text[offset + 1]) & 0x3FU) << 6) |
|
||||||
|
(static_cast<unsigned char>(text[offset + 2]) & 0x3FU);
|
||||||
|
}
|
||||||
|
return ((lead & 0x07U) << 18) |
|
||||||
|
((static_cast<unsigned char>(text[offset + 1]) & 0x3FU) << 12) |
|
||||||
|
((static_cast<unsigned char>(text[offset + 2]) & 0x3FU) << 6) |
|
||||||
|
(static_cast<unsigned char>(text[offset + 3]) & 0x3FU);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string get_env(const char* name) {
|
||||||
|
const char* value = std::getenv(name);
|
||||||
|
return value == nullptr ? std::string() : std::string(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string trim_copy(std::string value) {
|
||||||
|
auto not_space = [](unsigned char c) { return !std::isspace(c); };
|
||||||
|
while (!value.empty() && !not_space(static_cast<unsigned char>(value.front()))) {
|
||||||
|
value.erase(value.begin());
|
||||||
|
}
|
||||||
|
while (!value.empty() && !not_space(static_cast<unsigned char>(value.back()))) {
|
||||||
|
value.pop_back();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string single_line(std::string text) {
|
||||||
|
for (char& c : text) {
|
||||||
|
if (c == '\n' || c == '\r' || c == '\t') {
|
||||||
|
c = ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trim_copy(std::move(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_decimal_number(const std::string& value) {
|
||||||
|
return !value.empty() &&
|
||||||
|
std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch); });
|
||||||
|
}
|
||||||
|
|
||||||
|
std::filesystem::path data_root() {
|
||||||
|
if (const char* xdg = std::getenv("XDG_DATA_HOME"); xdg != nullptr && *xdg != '\0') {
|
||||||
|
return std::filesystem::path(xdg) / "telegram-tui";
|
||||||
|
}
|
||||||
|
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
|
||||||
|
return std::filesystem::path(home) / ".local" / "share" / "telegram-tui";
|
||||||
|
}
|
||||||
|
return std::filesystem::current_path() / ".telegram-tui-data";
|
||||||
|
}
|
||||||
|
|
||||||
|
StoredConfig load_app_config() {
|
||||||
|
const std::filesystem::path path = data_root() / "config.json";
|
||||||
|
std::ifstream input(path);
|
||||||
|
if (!input.is_open()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json config = json::parse(input, nullptr, true, true);
|
||||||
|
if (!config.is_object()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return StoredConfig{
|
||||||
|
safe_string(config, "api_id"),
|
||||||
|
safe_string(config, "api_hash"),
|
||||||
|
config.value("auto_reload_chat_history", false),
|
||||||
|
};
|
||||||
|
} catch (const json::exception&) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool save_app_config(const StoredConfig& config) {
|
||||||
|
const std::filesystem::path path = data_root() / "config.json";
|
||||||
|
try {
|
||||||
|
if (path.has_parent_path()) {
|
||||||
|
std::filesystem::create_directories(path.parent_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
json document = json::object();
|
||||||
|
if (!config.api_id.empty()) {
|
||||||
|
document["api_id"] = config.api_id;
|
||||||
|
}
|
||||||
|
if (!config.api_hash.empty()) {
|
||||||
|
document["api_hash"] = config.api_hash;
|
||||||
|
}
|
||||||
|
document["auto_reload_chat_history"] = config.auto_reload_chat_history;
|
||||||
|
|
||||||
|
std::ofstream output(path, std::ios::trunc);
|
||||||
|
if (!output.is_open()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
output << document.dump(2) << '\n';
|
||||||
|
return static_cast<bool>(output);
|
||||||
|
} catch (const std::exception&) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string safe_string(const json& object, const char* key) {
|
||||||
|
if (!object.contains(key) || !object.at(key).is_string()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return object.at(key).get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int64_t safe_i64(const json& object, const char* key) {
|
||||||
|
if (!object.contains(key) || !object.at(key).is_number_integer()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return object.at(key).get<std::int64_t>();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::int32_t safe_i32(const json& object, const char* key) {
|
||||||
|
if (!object.contains(key) || !object.at(key).is_number_integer()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return object.at(key).get<std::int32_t>();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_time(std::int32_t unix_time) {
|
||||||
|
if (unix_time <= 0) {
|
||||||
|
return "--:--";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::time_t raw = unix_time;
|
||||||
|
std::tm tm = *std::localtime(&raw);
|
||||||
|
char buffer[16] = {};
|
||||||
|
std::strftime(buffer, sizeof(buffer), "%H:%M", &tm);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_date(std::int32_t unix_time) {
|
||||||
|
if (unix_time <= 0) {
|
||||||
|
return "Unknown day";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::time_t raw = unix_time;
|
||||||
|
std::tm tm = *std::localtime(&raw);
|
||||||
|
char buffer[32] = {};
|
||||||
|
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &tm);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_datetime(std::int32_t unix_time) {
|
||||||
|
if (unix_time <= 0) {
|
||||||
|
return "Unknown time";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::time_t raw = unix_time;
|
||||||
|
std::tm tm = *std::localtime(&raw);
|
||||||
|
char buffer[32] = {};
|
||||||
|
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M", &tm);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_file_size(std::int64_t size_bytes) {
|
||||||
|
if (size_bytes <= 0) {
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
static constexpr const char* units[] = {"B", "KB", "MB", "GB", "TB"};
|
||||||
|
double size = static_cast<double>(size_bytes);
|
||||||
|
std::size_t unit_index = 0;
|
||||||
|
while (size >= 1024.0 && unit_index + 1 < std::size(units)) {
|
||||||
|
size /= 1024.0;
|
||||||
|
++unit_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream.setf(std::ios::fixed);
|
||||||
|
stream.precision(unit_index == 0 ? 0 : 1);
|
||||||
|
stream << size << ' ' << units[unit_index];
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> wrap_text(const std::string& text, int width) {
|
||||||
|
if (width <= 1) {
|
||||||
|
return {text};
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::stringstream stream(text);
|
||||||
|
std::string paragraph;
|
||||||
|
while (std::getline(stream, paragraph, '\n')) {
|
||||||
|
paragraph = single_line(std::move(paragraph));
|
||||||
|
if (paragraph.empty()) {
|
||||||
|
lines.emplace_back();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::stringstream words(paragraph);
|
||||||
|
std::string word;
|
||||||
|
std::string current;
|
||||||
|
while (words >> word) {
|
||||||
|
if (current.empty()) {
|
||||||
|
current = word;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (static_cast<int>(current.size() + 1 + word.size()) > width) {
|
||||||
|
lines.push_back(current);
|
||||||
|
current = word;
|
||||||
|
} else {
|
||||||
|
current += " ";
|
||||||
|
current += word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!current.empty()) {
|
||||||
|
lines.push_back(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.empty()) {
|
||||||
|
lines.emplace_back();
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset) {
|
||||||
|
std::size_t byte_index = 0;
|
||||||
|
std::size_t utf16_units = 0;
|
||||||
|
while (byte_index < text.size() && utf16_units < utf16_offset) {
|
||||||
|
std::size_t size = 0;
|
||||||
|
const std::uint32_t codepoint = decode_utf8_codepoint(text, byte_index, &size);
|
||||||
|
if (size == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const std::size_t units = codepoint > 0xFFFFU ? 2 : 1;
|
||||||
|
if (utf16_units + units > utf16_offset) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
utf16_units += units;
|
||||||
|
byte_index += size;
|
||||||
|
}
|
||||||
|
return byte_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index) {
|
||||||
|
if (byte_index == 0 || text.empty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t index = std::min(byte_index, text.size()) - 1;
|
||||||
|
while (index > 0 && is_utf8_continuation(static_cast<unsigned char>(text[index]))) {
|
||||||
|
--index;
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t utf8_next_index(const std::string& text, std::size_t byte_index) {
|
||||||
|
if (byte_index >= text.size()) {
|
||||||
|
return text.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t size = 0;
|
||||||
|
decode_utf8_codepoint(text, byte_index, &size);
|
||||||
|
return std::min(text.size(), byte_index + std::max<std::size_t>(1, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
int utf8_display_width(const std::string& text, std::size_t byte_limit) {
|
||||||
|
const std::size_t limit = std::min(byte_limit, text.size());
|
||||||
|
int width = 0;
|
||||||
|
std::size_t index = 0;
|
||||||
|
while (index < limit) {
|
||||||
|
std::size_t size = 0;
|
||||||
|
const std::uint32_t codepoint = decode_utf8_codepoint(text, index, &size);
|
||||||
|
if (size == 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
int char_width = 1;
|
||||||
|
if (codepoint <= static_cast<std::uint32_t>(WCHAR_MAX)) {
|
||||||
|
const int measured = ::wcwidth(static_cast<wchar_t>(codepoint));
|
||||||
|
if (measured > 0) {
|
||||||
|
char_width = measured;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
width += char_width;
|
||||||
|
index += size;
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pop_utf8_back(std::string& text) {
|
||||||
|
if (text.empty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t start = text.size() - 1;
|
||||||
|
while (start > 0 && is_utf8_continuation(static_cast<unsigned char>(text[start]))) {
|
||||||
|
--start;
|
||||||
|
}
|
||||||
|
text.erase(start);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
39
src/util.h
Normal file
39
src/util.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "json.h"
|
||||||
|
|
||||||
|
namespace telegram_tui {
|
||||||
|
|
||||||
|
struct StoredConfig {
|
||||||
|
std::string api_id;
|
||||||
|
std::string api_hash;
|
||||||
|
bool auto_reload_chat_history = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] std::string get_env(const char* name);
|
||||||
|
[[nodiscard]] std::string trim_copy(std::string value);
|
||||||
|
[[nodiscard]] std::string single_line(std::string text);
|
||||||
|
[[nodiscard]] bool is_decimal_number(const std::string& value);
|
||||||
|
[[nodiscard]] std::filesystem::path data_root();
|
||||||
|
[[nodiscard]] StoredConfig load_app_config();
|
||||||
|
bool save_app_config(const StoredConfig& config);
|
||||||
|
[[nodiscard]] std::string safe_string(const json& object, const char* key);
|
||||||
|
[[nodiscard]] std::int64_t safe_i64(const json& object, const char* key);
|
||||||
|
[[nodiscard]] std::int32_t safe_i32(const json& object, const char* key);
|
||||||
|
[[nodiscard]] std::string format_time(std::int32_t unix_time);
|
||||||
|
[[nodiscard]] std::string format_date(std::int32_t unix_time);
|
||||||
|
[[nodiscard]] std::string format_datetime(std::int32_t unix_time);
|
||||||
|
[[nodiscard]] std::string format_file_size(std::int64_t size_bytes);
|
||||||
|
[[nodiscard]] std::vector<std::string> wrap_text(const std::string& text, int width);
|
||||||
|
[[nodiscard]] std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset);
|
||||||
|
[[nodiscard]] std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index);
|
||||||
|
[[nodiscard]] std::size_t utf8_next_index(const std::string& text, std::size_t byte_index);
|
||||||
|
[[nodiscard]] int utf8_display_width(const std::string& text, std::size_t byte_limit = std::string::npos);
|
||||||
|
void pop_utf8_back(std::string& text);
|
||||||
|
|
||||||
|
} // namespace telegram_tui
|
||||||
Reference in New Issue
Block a user