Line data Source code
1 : /* SPDX-License-Identifier: GPL-3.0-or-later */
2 : /* Copyright 2026 Peter Csaszar */
3 :
4 : /**
5 : * @file main/tg_tui.c
6 : * @brief tg-tui — interactive Telegram client entry point.
7 : *
8 : * V1 is an interactive command shell (readline-backed). Since P5-03/06
9 : * landed, the TUI also links tg-domain-write and exposes send / reply /
10 : * edit / delete / forward / read commands. A full curses-style TUI with
11 : * panes and live redraw is tracked under US-11 v2.
12 : */
13 :
14 : #include "app/bootstrap.h"
15 : #include "app/auth_flow.h"
16 : #include "app/config_wizard.h"
17 : #include "app/credentials.h"
18 : #include "app/dc_config.h"
19 : #include "app/session_store.h"
20 : #include "infrastructure/auth_logout.h"
21 : #include "logger.h"
22 :
23 : #include "readline.h"
24 : #include "platform/terminal.h"
25 : #include "arg_parse.h"
26 :
27 : #include "domain/read/self.h"
28 : #include "domain/read/dialogs.h"
29 : #include "domain/read/history.h"
30 : #include "domain/read/updates.h"
31 : #include "domain/read/user_info.h"
32 : #include "domain/read/contacts.h"
33 : #include "domain/read/search.h"
34 : #include "domain/read/media.h"
35 :
36 : #include "infrastructure/media_index.h"
37 :
38 : #include "platform/path.h"
39 : #include "fs_util.h"
40 :
41 : #include "domain/write/send.h"
42 : #include "domain/write/edit.h"
43 : #include "domain/write/delete.h"
44 : #include "domain/write/forward.h"
45 : #include "domain/write/read_history.h"
46 : #include "domain/write/upload.h"
47 :
48 : #include "tui/app.h"
49 :
50 : #include <stdio.h>
51 : #include <stdlib.h>
52 : #include <string.h>
53 :
54 : /* ---- Credential callbacks: batch values fall back to interactive prompts ---- */
55 :
56 : typedef struct {
57 : LineHistory *hist;
58 : const char *phone; /**< from --phone (may be NULL → interactive) */
59 : const char *code; /**< from --code (may be NULL → interactive) */
60 : const char *password; /**< from --password (may be NULL → interactive) */
61 : } PromptCtx;
62 :
63 0 : static int tui_read_line(const char *label, char *out, size_t cap,
64 : LineHistory *hist) {
65 : char prompt[64];
66 0 : snprintf(prompt, sizeof(prompt), "%s: ", label);
67 0 : int n = rl_readline(prompt, out, cap, hist);
68 0 : return (n < 0) ? -1 : 0;
69 : }
70 :
71 0 : static int cb_get_phone(void *u, char *out, size_t cap) {
72 0 : PromptCtx *c = (PromptCtx *)u;
73 0 : if (c->phone) { snprintf(out, cap, "%s", c->phone); return 0; }
74 0 : return tui_read_line("phone (+...)", out, cap, c->hist);
75 : }
76 0 : static int cb_get_code(void *u, char *out, size_t cap) {
77 0 : PromptCtx *c = (PromptCtx *)u;
78 0 : if (c->code) { snprintf(out, cap, "%s", c->code); return 0; }
79 0 : return tui_read_line("code", out, cap, c->hist);
80 : }
81 0 : static int cb_get_password(void *u, char *out, size_t cap) {
82 0 : PromptCtx *c = (PromptCtx *)u;
83 0 : if (c->password) { snprintf(out, cap, "%s", c->password); return 0; }
84 0 : return tui_read_line("2FA password", out, cap, c->hist);
85 : }
86 :
87 : /* ---- Forward label helpers ---- */
88 :
89 : static const char *kind_label(DialogPeerKind k);
90 : static const char *resolved_label(ResolvedKind k);
91 :
92 : /* ---- Commands dispatched from the interactive loop ---- */
93 :
94 0 : static void do_me(const ApiConfig *cfg, MtProtoSession *s, Transport *t) {
95 0 : SelfInfo me = {0};
96 0 : if (domain_get_self(cfg, s, t, &me) != 0) {
97 0 : puts("me: request failed");
98 0 : return;
99 : }
100 0 : printf("id=%lld @%s %s %s +%s premium=%s\n",
101 0 : (long long)me.id,
102 0 : me.username[0] ? me.username : "(none)",
103 : me.first_name, me.last_name,
104 0 : me.phone[0] ? me.phone : "(hidden)",
105 0 : me.is_premium ? "yes" : "no");
106 : }
107 :
108 0 : static void do_dialogs(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
109 : int limit) {
110 0 : if (limit <= 0 || limit > 100) limit = 20;
111 0 : DialogEntry entries[100] = {0};
112 0 : int count = 0;
113 0 : if (domain_get_dialogs(cfg, s, t, limit, 0, entries, &count, NULL) != 0) {
114 0 : puts("dialogs: request failed");
115 0 : return;
116 : }
117 0 : printf("%-8s %6s %-32s %s\n", "type", "unread", "title", "@username / id");
118 0 : for (int i = 0; i < count; i++) {
119 0 : const char *title = entries[i].title[0] ? entries[i].title : "(no title)";
120 0 : if (entries[i].username[0]) {
121 0 : printf("%-8s %6d %-32s @%s\n",
122 : kind_label(entries[i].kind),
123 0 : entries[i].unread_count, title, entries[i].username);
124 : } else {
125 0 : printf("%-8s %6d %-32s %lld\n",
126 : kind_label(entries[i].kind),
127 : entries[i].unread_count, title,
128 0 : (long long)entries[i].peer_id);
129 : }
130 : }
131 : }
132 :
133 0 : static const char *kind_label(DialogPeerKind k) {
134 0 : switch (k) {
135 0 : case DIALOG_PEER_USER: return "user";
136 0 : case DIALOG_PEER_CHAT: return "chat";
137 0 : case DIALOG_PEER_CHANNEL: return "channel";
138 0 : default: return "unknown";
139 : }
140 : }
141 :
142 0 : static const char *resolved_label(ResolvedKind k) {
143 0 : switch (k) {
144 0 : case RESOLVED_KIND_USER: return "user";
145 0 : case RESOLVED_KIND_CHAT: return "chat";
146 0 : case RESOLVED_KIND_CHANNEL: return "channel";
147 0 : default: return "unknown";
148 : }
149 : }
150 :
151 0 : static int resolve_history_peer(const ApiConfig *cfg, MtProtoSession *s,
152 : Transport *t, const char *arg,
153 : HistoryPeer *out) {
154 0 : if (!arg || !*arg || !strcmp(arg, "self")) {
155 0 : out->kind = HISTORY_PEER_SELF; out->peer_id = 0; out->access_hash = 0;
156 0 : return 0;
157 : }
158 0 : ResolvedPeer rp = {0};
159 0 : if (domain_resolve_username(cfg, s, t, arg, &rp) != 0) return -1;
160 0 : switch (rp.kind) {
161 0 : case RESOLVED_KIND_USER: out->kind = HISTORY_PEER_USER; break;
162 0 : case RESOLVED_KIND_CHANNEL: out->kind = HISTORY_PEER_CHANNEL; break;
163 0 : case RESOLVED_KIND_CHAT: out->kind = HISTORY_PEER_CHAT; break;
164 0 : default: return -1;
165 : }
166 0 : out->peer_id = rp.id;
167 0 : out->access_hash = rp.access_hash;
168 0 : if ((out->kind == HISTORY_PEER_USER || out->kind == HISTORY_PEER_CHANNEL)
169 0 : && !rp.have_hash) return -1;
170 0 : return 0;
171 : }
172 :
173 0 : static void do_history_any(const ApiConfig *cfg, MtProtoSession *s,
174 : Transport *t, const char *arg, int limit) {
175 0 : if (limit <= 0 || limit > 100) limit = 20;
176 0 : HistoryPeer peer = {0};
177 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
178 0 : printf("history: could not resolve '%s'\n", arg ? arg : "self");
179 0 : return;
180 : }
181 0 : HistoryEntry entries[100] = {0};
182 0 : int count = 0;
183 0 : if (domain_get_history(cfg, s, t, &peer, 0, limit, entries, &count) != 0) {
184 0 : puts("history: request failed");
185 0 : return;
186 : }
187 0 : for (int i = 0; i < count; i++) {
188 0 : printf("[%d] %s %lld %s\n",
189 : entries[i].id,
190 0 : entries[i].out ? ">" : "<",
191 0 : (long long)entries[i].date,
192 0 : entries[i].complex ? "(complex — text not parsed)"
193 : : entries[i].text);
194 : }
195 0 : if (count == 0) puts("(no messages)");
196 : }
197 :
198 0 : static void do_contacts(const ApiConfig *cfg, MtProtoSession *s, Transport *t) {
199 0 : ContactEntry entries[64] = {0};
200 0 : int count = 0;
201 0 : if (domain_get_contacts(cfg, s, t, entries, 64, &count) != 0) {
202 0 : puts("contacts: request failed");
203 0 : return;
204 : }
205 0 : printf("%-18s %s\n", "user_id", "mutual");
206 0 : for (int i = 0; i < count; i++) {
207 0 : printf("%-18lld %s\n",
208 0 : (long long)entries[i].user_id,
209 0 : entries[i].mutual ? "yes" : "no");
210 : }
211 0 : if (count == 0) puts("(no contacts)");
212 : }
213 :
214 0 : static void do_info(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
215 : const char *peer) {
216 0 : if (!peer || !*peer) { puts("usage: info <@name>"); return; }
217 0 : ResolvedPeer r = {0};
218 0 : if (domain_resolve_username(cfg, s, t, peer, &r) != 0) {
219 0 : puts("info: resolve failed");
220 0 : return;
221 : }
222 0 : printf("type: %s\nid: %lld\nusername: @%s\n"
223 : "access_hash: %s\n",
224 : resolved_label(r.kind),
225 0 : (long long)r.id,
226 0 : r.username[0] ? r.username : "",
227 0 : r.have_hash ? "present" : "none");
228 : }
229 :
230 : /* Returns 1 if the token looks like a peer specifier:
231 : * @username, a numeric id, or the literal "self". */
232 0 : static int is_peer_token(const char *tok) {
233 0 : if (!tok || !*tok) return 0;
234 0 : if (*tok == '@') return tok[1] != '\0';
235 0 : if (!strcmp(tok, "self")) return 1;
236 : /* Pure numeric (possibly negative) → peer id */
237 0 : const char *p = tok;
238 0 : if (*p == '-') p++;
239 0 : if (!*p) return 0;
240 0 : while (*p >= '0' && *p <= '9') p++;
241 0 : return (*p == '\0');
242 : }
243 :
244 0 : static void do_search(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
245 : char *arg) {
246 0 : if (!arg || !*arg) {
247 0 : puts("usage: search [<peer>] <query>\n"
248 : " <peer> @username, numeric id, or 'self' (omit for global search)");
249 0 : return;
250 : }
251 :
252 : /* Split arg on the first whitespace to check for an optional peer token. */
253 0 : char peer_buf[128] = "";
254 0 : const char *query = arg;
255 0 : char *space = arg;
256 0 : while (*space && *space != ' ' && *space != '\t') space++;
257 0 : if (*space) {
258 : /* There is a second token — check if the first is a peer. */
259 0 : size_t pn = (size_t)(space - arg);
260 0 : char first[128] = "";
261 0 : if (pn < sizeof(first)) { memcpy(first, arg, pn); first[pn] = '\0'; }
262 0 : if (is_peer_token(first)) {
263 0 : memcpy(peer_buf, first, pn + 1);
264 0 : query = space;
265 0 : while (*query == ' ' || *query == '\t') query++;
266 : }
267 : }
268 :
269 0 : if (!*query) {
270 0 : puts("usage: search [<peer>] <query>");
271 0 : return;
272 : }
273 :
274 0 : HistoryEntry e[20] = {0};
275 0 : int n = 0;
276 :
277 0 : if (peer_buf[0]) {
278 : /* Per-peer search */
279 0 : HistoryPeer peer = {0};
280 0 : if (resolve_history_peer(cfg, s, t, peer_buf, &peer) != 0) {
281 0 : printf("search: cannot resolve '%s'\n", peer_buf);
282 0 : return;
283 : }
284 0 : if (domain_search_peer(cfg, s, t, &peer, query, 20, e, &n) != 0) {
285 0 : puts("search: request failed");
286 0 : return;
287 : }
288 : } else {
289 : /* Global search */
290 0 : if (domain_search_global(cfg, s, t, query, 20, e, &n) != 0) {
291 0 : puts("search: request failed");
292 0 : return;
293 : }
294 : }
295 :
296 0 : for (int i = 0; i < n; i++) {
297 0 : printf("%d %lld %s\n", e[i].id, (long long)e[i].date,
298 0 : e[i].complex ? "(complex)" : e[i].text);
299 : }
300 0 : if (n == 0) puts("(no matches)");
301 : }
302 :
303 : /* ---- Write commands ---- */
304 :
305 : /* Split "arg" on the first whitespace. Returns pointer to the remainder
306 : * (may be empty). The first token is NUL-terminated in place. */
307 0 : static char *split_rest(char *arg) {
308 0 : while (*arg && *arg != ' ' && *arg != '\t') arg++;
309 0 : if (!*arg) return arg;
310 0 : *arg++ = '\0';
311 0 : while (*arg == ' ' || *arg == '\t') arg++;
312 0 : return arg;
313 : }
314 :
315 0 : static void do_send(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
316 : char *arg) {
317 0 : if (!arg || !*arg) { puts("usage: send <peer> <text>"); return; }
318 0 : char *text = split_rest(arg);
319 0 : if (!*text) { puts("usage: send <peer> <text>"); return; }
320 :
321 0 : HistoryPeer peer = {0};
322 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
323 0 : printf("send: cannot resolve '%s'\n", arg);
324 0 : return;
325 : }
326 0 : int32_t new_id = 0;
327 0 : RpcError err = {0};
328 0 : if (domain_send_message(cfg, s, t, &peer, text, &new_id, &err) != 0) {
329 0 : printf("send: failed (%d: %s)\n", err.error_code, err.error_msg);
330 0 : return;
331 : }
332 0 : if (new_id > 0) printf("sent, id=%d\n", new_id);
333 0 : else puts("sent");
334 : }
335 :
336 0 : static void do_reply(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
337 : char *arg) {
338 0 : if (!arg || !*arg) { puts("usage: reply <peer> <msg_id> <text>"); return; }
339 0 : char *rest = split_rest(arg);
340 0 : if (!*rest) { puts("usage: reply <peer> <msg_id> <text>"); return; }
341 0 : char *text = split_rest(rest);
342 0 : if (!*text) { puts("usage: reply <peer> <msg_id> <text>"); return; }
343 0 : int32_t mid = atoi(rest);
344 0 : if (mid <= 0) { puts("reply: <msg_id> must be positive"); return; }
345 :
346 0 : HistoryPeer peer = {0};
347 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
348 0 : printf("reply: cannot resolve '%s'\n", arg); return;
349 : }
350 0 : int32_t new_id = 0; RpcError err = {0};
351 0 : if (domain_send_message_reply(cfg, s, t, &peer, text, mid,
352 : &new_id, &err) != 0) {
353 0 : printf("reply: failed (%d: %s)\n", err.error_code, err.error_msg);
354 0 : return;
355 : }
356 0 : if (new_id > 0) printf("sent, id=%d (reply to %d)\n", new_id, mid);
357 0 : else printf("sent (reply to %d)\n", mid);
358 : }
359 :
360 0 : static void do_edit(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
361 : char *arg) {
362 0 : if (!arg || !*arg) { puts("usage: edit <peer> <msg_id> <text>"); return; }
363 0 : char *rest = split_rest(arg);
364 0 : if (!*rest) { puts("usage: edit <peer> <msg_id> <text>"); return; }
365 0 : char *text = split_rest(rest);
366 0 : if (!*text) { puts("usage: edit <peer> <msg_id> <text>"); return; }
367 0 : int32_t mid = atoi(rest);
368 0 : if (mid <= 0) { puts("edit: <msg_id> must be positive"); return; }
369 :
370 0 : HistoryPeer peer = {0};
371 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
372 0 : printf("edit: cannot resolve '%s'\n", arg); return;
373 : }
374 0 : RpcError err = {0};
375 0 : if (domain_edit_message(cfg, s, t, &peer, mid, text, &err) != 0) {
376 0 : printf("edit: failed (%d: %s)\n", err.error_code, err.error_msg);
377 0 : return;
378 : }
379 0 : printf("edited %d\n", mid);
380 : }
381 :
382 0 : static void do_delete(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
383 : char *arg) {
384 0 : if (!arg || !*arg) { puts("usage: delete <peer> <msg_id> [revoke]"); return; }
385 0 : char *rest = split_rest(arg);
386 0 : if (!*rest) { puts("usage: delete <peer> <msg_id> [revoke]"); return; }
387 0 : int revoke = 0;
388 0 : char *id_tok = rest;
389 0 : char *extra = split_rest(rest);
390 0 : if (*extra && !strcmp(extra, "revoke")) revoke = 1;
391 0 : int32_t mid = atoi(id_tok);
392 0 : if (mid <= 0) { puts("delete: <msg_id> must be positive"); return; }
393 :
394 0 : HistoryPeer peer = {0};
395 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
396 0 : printf("delete: cannot resolve '%s'\n", arg); return;
397 : }
398 0 : int32_t ids[1] = { mid };
399 0 : RpcError err = {0};
400 0 : if (domain_delete_messages(cfg, s, t, &peer, ids, 1, revoke, &err) != 0) {
401 0 : printf("delete: failed (%d: %s)\n", err.error_code, err.error_msg);
402 0 : return;
403 : }
404 0 : printf("deleted %d%s\n", mid, revoke ? " (revoke)" : "");
405 : }
406 :
407 0 : static void do_forward(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
408 : char *arg) {
409 0 : if (!arg || !*arg) { puts("usage: forward <from> <to> <msg_id>"); return; }
410 0 : char *rest = split_rest(arg);
411 0 : if (!*rest) { puts("usage: forward <from> <to> <msg_id>"); return; }
412 0 : char *id_tok = split_rest(rest);
413 0 : if (!*id_tok) { puts("usage: forward <from> <to> <msg_id>"); return; }
414 0 : int32_t mid = atoi(id_tok);
415 0 : if (mid <= 0) { puts("forward: <msg_id> must be positive"); return; }
416 :
417 0 : HistoryPeer from = {0}, to = {0};
418 0 : if (resolve_history_peer(cfg, s, t, arg, &from) != 0
419 0 : || resolve_history_peer(cfg, s, t, rest, &to) != 0) {
420 0 : puts("forward: cannot resolve peers"); return;
421 : }
422 0 : int32_t ids[1] = { mid };
423 0 : RpcError err = {0};
424 0 : if (domain_forward_messages(cfg, s, t, &from, &to, ids, 1, &err) != 0) {
425 0 : printf("forward: failed (%d: %s)\n", err.error_code, err.error_msg);
426 0 : return;
427 : }
428 0 : printf("forwarded %d\n", mid);
429 : }
430 :
431 0 : static void do_upload(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
432 : char *arg) {
433 0 : if (!arg || !*arg) { puts("usage: upload <peer> <path> [caption]"); return; }
434 0 : char *path = split_rest(arg);
435 0 : if (!*path) { puts("usage: upload <peer> <path> [caption]"); return; }
436 0 : char *caption = split_rest(path);
437 0 : HistoryPeer peer = {0};
438 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
439 0 : printf("upload: cannot resolve '%s'\n", arg); return;
440 : }
441 0 : RpcError err = {0};
442 0 : int as_photo = domain_path_is_image(path);
443 0 : int rc = as_photo
444 0 : ? domain_send_photo(cfg, s, t, &peer, path,
445 0 : *caption ? caption : NULL, &err)
446 0 : : domain_send_file (cfg, s, t, &peer, path,
447 0 : *caption ? caption : NULL, NULL, &err);
448 0 : if (rc != 0) {
449 0 : printf("upload: failed (%d: %s)\n", err.error_code, err.error_msg);
450 0 : return;
451 : }
452 0 : printf("uploaded %s as %s\n", path, as_photo ? "photo" : "document");
453 : }
454 :
455 0 : static void do_download(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
456 : char *arg) {
457 : /* Syntax: download <peer> <msg_id> [out] */
458 0 : if (!arg || !*arg) {
459 0 : puts("usage: download <peer> <msg_id> [out]");
460 0 : return;
461 : }
462 0 : char *rest = split_rest(arg);
463 0 : if (!*rest) { puts("usage: download <peer> <msg_id> [out]"); return; }
464 0 : char *out_arg = split_rest(rest);
465 :
466 0 : int32_t mid = atoi(rest);
467 0 : if (mid <= 0) { puts("download: <msg_id> must be positive"); return; }
468 :
469 0 : HistoryPeer peer = {0};
470 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
471 0 : printf("download: cannot resolve '%s'\n", arg);
472 0 : return;
473 : }
474 :
475 : /* Fetch the single message using offset_id = msg_id + 1, limit = 1. */
476 0 : HistoryEntry entry = {0};
477 0 : int count = 0;
478 0 : if (domain_get_history(cfg, s, t, &peer, mid + 1, 1, &entry, &count) != 0
479 0 : || count == 0 || entry.id != mid) {
480 0 : printf("download: message %d not found in this peer\n", mid);
481 0 : return;
482 : }
483 0 : if (entry.media != MEDIA_PHOTO && entry.media != MEDIA_DOCUMENT) {
484 0 : printf("download: message %d has no downloadable photo/document "
485 0 : "(media kind=%d)\n", mid, (int)entry.media);
486 0 : return;
487 : }
488 0 : if (entry.media_info.access_hash == 0
489 0 : || entry.media_info.file_reference_len == 0) {
490 0 : puts("download: missing access_hash or file_reference");
491 0 : return;
492 : }
493 :
494 : /* Compose output path: explicit [out] > default cache path. */
495 : char path_buf[2048];
496 0 : const char *out_path = (*out_arg) ? out_arg : NULL;
497 0 : if (!out_path) {
498 0 : const char *cache = platform_cache_dir();
499 0 : if (!cache) cache = "/tmp";
500 : char dir_buf[1536];
501 0 : snprintf(dir_buf, sizeof(dir_buf), "%s/tg-cli/downloads", cache);
502 0 : fs_mkdir_p(dir_buf, 0700);
503 0 : if (entry.media == MEDIA_DOCUMENT) {
504 0 : const char *fn = entry.media_info.document_filename;
505 0 : if (fn[0]) {
506 0 : snprintf(path_buf, sizeof(path_buf), "%s/%s", dir_buf, fn);
507 : } else {
508 0 : snprintf(path_buf, sizeof(path_buf), "%s/doc-%lld",
509 0 : dir_buf, (long long)entry.media_info.document_id);
510 : }
511 : } else {
512 0 : snprintf(path_buf, sizeof(path_buf), "%s/photo-%lld.jpg",
513 0 : dir_buf, (long long)entry.media_info.photo_id);
514 : }
515 0 : out_path = path_buf;
516 : }
517 :
518 0 : if (domain_download_media_cross_dc(cfg, s, t, &entry.media_info,
519 : out_path) != 0) {
520 0 : puts("download: failed (see logs)");
521 0 : return;
522 : }
523 :
524 : /* Record in the media index so `history` can show inline paths. */
525 0 : int64_t media_id = (entry.media == MEDIA_DOCUMENT)
526 : ? entry.media_info.document_id
527 0 : : entry.media_info.photo_id;
528 0 : if (media_index_put(media_id, out_path) != 0) {
529 0 : puts("download: warning: failed to update media index");
530 : }
531 :
532 0 : printf("saved: %s\n", out_path);
533 : }
534 :
535 0 : static void do_read(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
536 : const char *arg) {
537 0 : if (!arg || !*arg) { puts("usage: read <peer>"); return; }
538 0 : HistoryPeer peer = {0};
539 0 : if (resolve_history_peer(cfg, s, t, arg, &peer) != 0) {
540 0 : printf("read: cannot resolve '%s'\n", arg); return;
541 : }
542 0 : RpcError err = {0};
543 0 : if (domain_mark_read(cfg, s, t, &peer, 0, &err) != 0) {
544 0 : printf("read: failed (%d: %s)\n", err.error_code, err.error_msg);
545 0 : return;
546 : }
547 0 : puts("marked as read");
548 : }
549 :
550 0 : static void do_poll(const ApiConfig *cfg, MtProtoSession *s, Transport *t) {
551 0 : UpdatesState st = {0};
552 0 : if (domain_updates_state(cfg, s, t, &st) != 0) {
553 0 : puts("poll: getState failed");
554 0 : return;
555 : }
556 0 : UpdatesDifference diff = {0};
557 0 : if (domain_updates_difference(cfg, s, t, &st, &diff) != 0) {
558 0 : puts("poll: getDifference failed");
559 0 : return;
560 : }
561 0 : printf("pts=%d new_messages=%d empty=%d\n",
562 : diff.next_state.pts, diff.new_messages_count, diff.is_empty);
563 : }
564 :
565 : /* ---- Help ---- */
566 :
567 0 : static void print_help(void) {
568 0 : puts(
569 : "Commands (accept '/' prefix too):\n"
570 : "\n"
571 : "Read commands:\n"
572 : " me, self Show own profile\n"
573 : " dialogs, list [N] List up to N dialogs (default 20)\n"
574 : " history [<peer>] [N] Saved Messages by default, or <peer>\n"
575 : " contacts List my contacts\n"
576 : " info <@peer> Resolve peer info\n"
577 : " search [<peer>] <query> Message search: per-peer or global (top 20)\n"
578 : " poll One-shot updates.getDifference\n"
579 : " read <peer> Mark peer's history as read\n"
580 : " download <peer> <msg_id> [out] Download photo or document from message\n"
581 : "\n"
582 : "Write commands:\n"
583 : " send <peer> <text> Send a text message\n"
584 : " reply <peer> <msg_id> <text> Send as a reply to msg_id\n"
585 : " edit <peer> <msg_id> <text> Edit a previously sent message\n"
586 : " delete, del <peer> <msg_id> [revoke] Delete a message\n"
587 : " forward, fwd <from> <to> <msg_id> Forward one message\n"
588 : " upload <peer> <path> [caption] Upload a file (document)\n"
589 : "\n"
590 : "Session:\n"
591 : " help, ? Show this help\n"
592 : " quit, exit, :q Leave the TUI (Ctrl-D also exits)\n"
593 : "\n"
594 : "Launch flags (pass on the command line, not inside the REPL):\n"
595 : " --help, -h Show this help and exit\n"
596 : " --version, -v Show version and exit\n"
597 : " --tui Curses-style three-pane UI instead of REPL\n"
598 : " --phone <number> Pre-fill login phone (E.164)\n"
599 : " --code <digits> Pre-fill SMS/app code\n"
600 : " --password <pass> Pre-fill 2FA password\n"
601 : " --logout Clear persisted session and exit\n"
602 : " login [--api-id N --api-hash HEX] [--force] First-run config wizard\n"
603 : " Interactive when stdin is a TTY. From a script, pass both\n"
604 : " flags; otherwise the command exits with 1 (never blocks on input).\n"
605 : "\n"
606 : "See man tg-tui(1) for the full reference.\n"
607 : );
608 0 : }
609 :
610 : /* ---- REPL ---- */
611 :
612 1 : static int repl(const ApiConfig *cfg, MtProtoSession *s, Transport *t,
613 : LineHistory *hist) {
614 : char line[512];
615 0 : for (;;) {
616 1 : int n = rl_readline("tg> ", line, sizeof(line), hist);
617 1 : if (n < 0) return 0; /* EOF / Ctrl-C */
618 0 : if (n == 0) continue;
619 :
620 0 : rl_history_add(hist, line);
621 :
622 : /* Parse the very small command language in-place. */
623 0 : char *cmd = line;
624 0 : while (*cmd == ' ' || *cmd == '\t') cmd++;
625 0 : if (*cmd == '\0') continue;
626 :
627 0 : char *arg = cmd;
628 0 : while (*arg && *arg != ' ' && *arg != '\t') arg++;
629 0 : if (*arg) { *arg++ = '\0'; while (*arg == ' ') arg++; }
630 :
631 : /* Allow an optional leading '/' for IRC-style commands. */
632 0 : if (*cmd == '/') cmd++;
633 :
634 0 : if (!strcmp(cmd, "quit") || !strcmp(cmd, "exit") ||
635 0 : !strcmp(cmd, ":q")) return 0;
636 0 : if (!strcmp(cmd, "help") || !strcmp(cmd, "?")) { print_help(); continue; }
637 0 : if (!strcmp(cmd, "me") || !strcmp(cmd, "self")) { do_me(cfg, s, t); continue; }
638 0 : if (!strcmp(cmd, "dialogs") || !strcmp(cmd, "list")) {
639 0 : do_dialogs(cfg, s, t, atoi(arg));
640 0 : continue;
641 : }
642 0 : if (!strcmp(cmd, "history")) {
643 : /* Accept "history <peer> <N>" or "history <N>" or "history". */
644 0 : char peer[128] = "";
645 0 : int lim = 0;
646 0 : if (*arg) {
647 0 : char *space = strpbrk(arg, " \t");
648 0 : if (space) {
649 0 : size_t pn = (size_t)(space - arg);
650 0 : if (pn >= sizeof(peer)) pn = sizeof(peer) - 1;
651 0 : memcpy(peer, arg, pn);
652 0 : peer[pn] = '\0';
653 0 : while (*space == ' ' || *space == '\t') space++;
654 0 : lim = atoi(space);
655 : } else {
656 0 : if (*arg >= '0' && *arg <= '9') {
657 0 : lim = atoi(arg);
658 : } else {
659 0 : size_t an = strlen(arg);
660 0 : if (an >= sizeof(peer)) an = sizeof(peer) - 1;
661 0 : memcpy(peer, arg, an);
662 0 : peer[an] = '\0';
663 : }
664 : }
665 : }
666 0 : do_history_any(cfg, s, t, peer[0] ? peer : NULL, lim);
667 0 : continue;
668 : }
669 0 : if (!strcmp(cmd, "contacts")) { do_contacts(cfg, s, t); continue; }
670 0 : if (!strcmp(cmd, "info")) { do_info(cfg, s, t, arg); continue; }
671 0 : if (!strcmp(cmd, "search")) { do_search(cfg, s, t, arg); continue; } /* arg is mutable */
672 0 : if (!strcmp(cmd, "poll")) { do_poll(cfg, s, t); continue; }
673 0 : if (!strcmp(cmd, "send")) { do_send(cfg, s, t, arg); continue; }
674 0 : if (!strcmp(cmd, "reply")) { do_reply(cfg, s, t, arg); continue; }
675 0 : if (!strcmp(cmd, "edit")) { do_edit(cfg, s, t, arg); continue; }
676 0 : if (!strcmp(cmd, "delete") || !strcmp(cmd, "del")) {
677 0 : do_delete(cfg, s, t, arg); continue;
678 : }
679 0 : if (!strcmp(cmd, "forward") || !strcmp(cmd, "fwd")) {
680 0 : do_forward(cfg, s, t, arg); continue;
681 : }
682 0 : if (!strcmp(cmd, "read")) { do_read(cfg, s, t, arg); continue; }
683 0 : if (!strcmp(cmd, "upload")) { do_upload(cfg, s, t, arg); continue; }
684 0 : if (!strcmp(cmd, "download")) { do_download(cfg, s, t, arg); continue; }
685 :
686 0 : printf("unknown command: %s (try 'help')\n", cmd);
687 : }
688 : }
689 :
690 :
691 : /* Map a DialogEntry to a HistoryPeer. Legacy groups don't need an
692 : * access_hash; users/channels do, and since TUI-08 the DialogEntry
693 : * carries it when the server sent one. */
694 0 : static int dialog_to_history_peer(const DialogEntry *d, HistoryPeer *out) {
695 0 : memset(out, 0, sizeof(*out));
696 0 : switch (d->kind) {
697 0 : case DIALOG_PEER_CHAT:
698 0 : out->kind = HISTORY_PEER_CHAT;
699 0 : out->peer_id = d->peer_id;
700 0 : return 0;
701 0 : case DIALOG_PEER_USER:
702 0 : if (!d->have_access_hash) return -1;
703 0 : out->kind = HISTORY_PEER_USER;
704 0 : out->peer_id = d->peer_id;
705 0 : out->access_hash = d->access_hash;
706 0 : return 0;
707 0 : case DIALOG_PEER_CHANNEL:
708 0 : if (!d->have_access_hash) return -1;
709 0 : out->kind = HISTORY_PEER_CHANNEL;
710 0 : out->peer_id = d->peer_id;
711 0 : out->access_hash = d->access_hash;
712 0 : return 0;
713 0 : default:
714 0 : return -1;
715 : }
716 : }
717 :
718 : /* Curses-style TUI loop (US-11 v2). Returns 0 on clean exit. */
719 4 : static int run_tui_loop(const ApiConfig *cfg,
720 : MtProtoSession *s, Transport *t) {
721 4 : int rows = terminal_rows(); if (rows < 3) rows = 24;
722 4 : int cols = terminal_cols(); if (cols < 40) cols = 80;
723 :
724 : TuiApp app;
725 4 : if (tui_app_init(&app, rows, cols) != 0) {
726 0 : fprintf(stderr, "tg-tui: cannot initialize TUI (size %dx%d)\n",
727 : rows, cols);
728 0 : return 1;
729 : }
730 :
731 : /* Prime the dialog list. Failure is non-fatal; the user sees the
732 : * empty-placeholder and can still quit. */
733 4 : if (dialog_pane_refresh(&app.dialogs, cfg, s, t) != 0) {
734 0 : status_row_set_message(&app.status, "dialogs: load failed");
735 : }
736 : /* dialog_pane_set_entries resets the list_view but forgets the
737 : * viewport height set in tui_app_init; restore it. */
738 4 : app.dialogs.lv.rows_visible = app.layout.dialogs.rows;
739 :
740 8 : RAII_TERM_RAW TermRawState *raw = terminal_raw_enter();
741 4 : if (!raw) {
742 0 : fprintf(stderr, "tg-tui: cannot enter raw mode\n");
743 0 : tui_app_free(&app);
744 0 : return 1;
745 : }
746 : /* Ensure SIGTERM / SIGHUP / SIGINT restore the terminal before exiting,
747 : * even if the default handler bypasses our RAII cleanup. */
748 4 : terminal_install_cleanup_handlers(raw);
749 4 : screen_cursor_visible(&app.screen, 0);
750 :
751 4 : terminal_enable_resize_notifications();
752 :
753 : /* Prime updates polling. If getState fails (eg. offline or server hiccup)
754 : * we silently skip live polling — the pane UI still works. */
755 4 : UpdatesState upd_state = {0};
756 4 : int upd_active = (domain_updates_state(cfg, s, t, &upd_state) == 0);
757 :
758 4 : tui_app_paint(&app);
759 4 : screen_flip(&app.screen);
760 :
761 : /* Poll cadence for updates.getDifference. 5 seconds is low enough to
762 : * feel live and high enough not to rate-limit ourselves. */
763 4 : const int POLL_INTERVAL_MS = 5000;
764 :
765 4 : int rc = 0;
766 2 : for (;;) {
767 6 : int ready = terminal_wait_key(POLL_INTERVAL_MS);
768 :
769 : /* SIGWINCH interrupts poll(): pick up the new size and repaint. */
770 6 : if (terminal_consume_resize()) {
771 2 : int nr = terminal_rows(); if (nr < 3) nr = app.rows;
772 2 : int nc = terminal_cols(); if (nc < 40) nc = app.cols;
773 2 : if (nr != app.rows || nc != app.cols) {
774 2 : if (tui_app_resize(&app, nr, nc) == 0) {
775 2 : screen_cursor_visible(&app.screen, 0);
776 2 : tui_app_paint(&app);
777 2 : screen_flip(&app.screen);
778 : }
779 : }
780 2 : if (ready < 0) continue; /* poll returned -1 (EINTR) */
781 : }
782 :
783 4 : if (ready == 0) {
784 : /* No keystroke within the poll window: consult the server
785 : * for any changes since we last asked. If anything came in,
786 : * refresh the dialog pane (titles / unread counts) and, if a
787 : * dialog is currently open, its history too. */
788 0 : if (upd_active) {
789 0 : UpdatesDifference diff = {0};
790 0 : if (domain_updates_difference(cfg, s, t,
791 : &upd_state, &diff) == 0) {
792 0 : upd_state = diff.next_state;
793 0 : int changed = !diff.is_empty
794 0 : && (diff.new_messages_count > 0
795 0 : || diff.other_updates_count > 0);
796 0 : if (changed) {
797 0 : dialog_pane_refresh(&app.dialogs, cfg, s, t);
798 0 : app.dialogs.lv.rows_visible = app.layout.dialogs.rows;
799 0 : if (app.history.peer_loaded) {
800 0 : history_pane_load(&app.history, cfg, s, t,
801 : &app.history.peer);
802 0 : app.history.lv.rows_visible = app.layout.history.rows;
803 : }
804 0 : tui_app_paint(&app);
805 0 : screen_flip(&app.screen);
806 : }
807 : }
808 : }
809 0 : continue;
810 : }
811 :
812 4 : if (ready < 0) continue; /* interrupted before any key — retry */
813 :
814 4 : TermKey key = terminal_read_key();
815 : TuiEvent ev;
816 4 : if (key == TERM_KEY_IGNORE) {
817 3 : ev = tui_app_handle_char(&app, terminal_last_printable());
818 : } else {
819 1 : ev = tui_app_handle_key(&app, key);
820 : }
821 :
822 4 : if (ev == TUI_EVENT_QUIT) break;
823 :
824 0 : if (ev == TUI_EVENT_OPEN_DIALOG) {
825 0 : const DialogEntry *d = dialog_pane_selected(&app.dialogs);
826 0 : if (d) {
827 : HistoryPeer peer;
828 0 : if (dialog_to_history_peer(d, &peer) == 0) {
829 0 : status_row_set_message(&app.status, "loading…");
830 0 : tui_app_paint(&app);
831 0 : screen_flip(&app.screen);
832 0 : if (history_pane_load(&app.history, cfg, s, t, &peer) == 0) {
833 : /* Restore the viewport height lost to the
834 : * history_pane_set_entries reset. */
835 0 : app.history.lv.rows_visible = app.layout.history.rows;
836 0 : status_row_set_message(&app.status, NULL);
837 : } else {
838 0 : status_row_set_message(&app.status, "history: load failed");
839 : }
840 : } else {
841 0 : status_row_set_message(&app.status,
842 : "cannot open (access_hash missing)");
843 : }
844 : }
845 : }
846 :
847 0 : if (ev != TUI_EVENT_NONE) {
848 0 : tui_app_paint(&app);
849 0 : screen_flip(&app.screen);
850 : }
851 : }
852 :
853 : /* Reset the terminal for the shell prompt. */
854 4 : screen_cursor_visible(&app.screen, 1);
855 4 : screen_cursor(&app.screen, app.rows, 1);
856 4 : fputs("\r\n", stdout);
857 4 : fflush(stdout);
858 4 : tui_app_free(&app);
859 4 : return rc;
860 : }
861 :
862 : /* Scan argv for --tui flag (used after full arg parse to decide mode). */
863 5 : static int has_tui_flag(int argc, char **argv) {
864 5 : for (int i = 1; i < argc; i++) {
865 4 : if (strcmp(argv[i], "--tui") == 0) return 1;
866 : }
867 1 : return 0;
868 : }
869 :
870 5 : int main(int argc, char **argv) {
871 5 : platform_normalize_argv(&argc, &argv);
872 : AppContext ctx;
873 5 : if (app_bootstrap(&ctx, "tg-tui") != 0) {
874 0 : fprintf(stderr, "tg-tui: bootstrap failed\n");
875 0 : return 1;
876 : }
877 :
878 : /* Drop the session-scoped resolver cache when the user logs out so a
879 : * subsequent login does not see stale @peer → id mappings. */
880 5 : auth_logout_set_cache_flush_cb(resolve_cache_flush);
881 :
882 : /* --logout: invalidate session server-side, then wipe the local file. */
883 9 : for (int i = 1; i < argc; i++) {
884 4 : if (strcmp(argv[i], "--logout") == 0) {
885 : ApiConfig cfg;
886 : MtProtoSession s;
887 : Transport t;
888 0 : if (credentials_load(&cfg) == 0) {
889 0 : transport_init(&t);
890 0 : mtproto_session_init(&s);
891 0 : int loaded_dc = 0;
892 0 : if (session_store_load(&s, &loaded_dc) == 0) {
893 0 : const DcEndpoint *ep = dc_lookup(loaded_dc);
894 0 : if (ep && transport_connect(&t, ep->host, ep->port) == 0) {
895 0 : t.dc_id = loaded_dc;
896 0 : auth_logout(&cfg, &s, &t);
897 0 : transport_close(&t);
898 : } else {
899 0 : logger_log(LOG_WARN,
900 : "tg-tui: logout: cannot connect to DC%d, clearing local session",
901 : loaded_dc);
902 0 : session_store_clear();
903 : }
904 : } else {
905 0 : session_store_clear();
906 : }
907 : } else {
908 0 : session_store_clear();
909 : }
910 0 : fprintf(stderr, "tg-tui: persisted session cleared.\n");
911 0 : app_shutdown(&ctx);
912 0 : return 0;
913 : }
914 : }
915 :
916 : /* Handle `login` subcommand before credentials are loaded.
917 : * Syntax: tg-tui login [--api-id N --api-hash HEX [--force]] */
918 9 : for (int i = 1; i < argc; i++) {
919 4 : if (strcmp(argv[i], "login") == 0) {
920 : /* Parse any --api-id / --api-hash / --force flags after "login". */
921 0 : const char *api_id_str = NULL;
922 0 : const char *api_hash_str = NULL;
923 0 : int force = 0;
924 0 : for (int j = i + 1; j < argc; j++) {
925 0 : if (strcmp(argv[j], "--api-id") == 0 && j + 1 < argc)
926 0 : { api_id_str = argv[++j]; }
927 0 : else if (strcmp(argv[j], "--api-hash") == 0 && j + 1 < argc)
928 0 : { api_hash_str = argv[++j]; }
929 0 : else if (strcmp(argv[j], "--force") == 0)
930 0 : { force = 1; }
931 : }
932 : int wrc;
933 0 : if (api_id_str || api_hash_str)
934 0 : wrc = config_wizard_run_batch(api_id_str, api_hash_str, force);
935 : else
936 0 : wrc = config_wizard_run_interactive();
937 0 : app_shutdown(&ctx);
938 0 : return wrc != 0 ? 1 : 0;
939 : }
940 : }
941 :
942 : /* Handle --help / --version before anything else. */
943 9 : for (int i = 1; i < argc; i++) {
944 4 : if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) {
945 0 : print_help();
946 0 : app_shutdown(&ctx);
947 0 : return 0;
948 : }
949 4 : if (strcmp(argv[i], "--version") == 0 || strcmp(argv[i], "-v") == 0) {
950 0 : arg_print_version();
951 0 : app_shutdown(&ctx);
952 0 : return 0;
953 : }
954 : }
955 :
956 : /* Parse --phone / --code / --password / --tui flags. */
957 5 : const char *opt_phone = NULL;
958 5 : const char *opt_code = NULL;
959 5 : const char *opt_password = NULL;
960 5 : int tui_mode = has_tui_flag(argc, argv);
961 :
962 9 : for (int i = 1; i < argc; i++) {
963 4 : if (strcmp(argv[i], "--phone") == 0 && i + 1 < argc) {
964 0 : opt_phone = argv[++i];
965 4 : } else if (strcmp(argv[i], "--code") == 0 && i + 1 < argc) {
966 0 : opt_code = argv[++i];
967 4 : } else if (strcmp(argv[i], "--password") == 0 && i + 1 < argc) {
968 0 : opt_password = argv[++i];
969 : }
970 : }
971 :
972 : ApiConfig cfg;
973 5 : if (credentials_load(&cfg) != 0) {
974 0 : app_shutdown(&ctx);
975 0 : return 1;
976 : }
977 :
978 : LineHistory hist;
979 5 : rl_history_init(&hist);
980 :
981 5 : PromptCtx pctx = {
982 : .hist = &hist,
983 : .phone = opt_phone,
984 : .code = opt_code,
985 : .password = opt_password,
986 : };
987 5 : AuthFlowCallbacks cb = {
988 : .get_phone = cb_get_phone,
989 : .get_code = cb_get_code,
990 : .get_password = cb_get_password,
991 : .user = &pctx,
992 : };
993 :
994 : MtProtoSession s;
995 : Transport t;
996 5 : transport_init(&t);
997 5 : mtproto_session_init(&s);
998 :
999 5 : if (!tui_mode) {
1000 1 : fputs("tg-tui — interactive Telegram client. "
1001 : "Type 'help' for commands (or run with --tui for pane view).\n",
1002 : stdout);
1003 : }
1004 :
1005 5 : if (auth_flow_login(&cfg, &cb, &t, &s, NULL) != 0) {
1006 0 : fprintf(stderr, "tg-tui: login failed\n");
1007 0 : transport_close(&t);
1008 0 : app_shutdown(&ctx);
1009 0 : return 1;
1010 : }
1011 :
1012 4 : int rc = tui_mode ? run_tui_loop(&cfg, &s, &t)
1013 5 : : repl(&cfg, &s, &t, &hist);
1014 5 : transport_close(&t);
1015 5 : app_shutdown(&ctx);
1016 5 : return rc;
1017 : }
|