LCOV - code coverage report
Current view: top level - src/main - tg_cli.c (source / functions) Coverage Total Hit
Test: coverage.info Lines: 0.9 % 665 6
Test Date: 2026-05-06 13:17:06 Functions: 3.3 % 30 1

            Line data    Source code
       1              : /* SPDX-License-Identifier: GPL-3.0-or-later */
       2              : /* Copyright 2026 Peter Csaszar */
       3              : 
       4              : /**
       5              :  * @file main/tg_cli.c
       6              :  * @brief tg-cli — batch read+write Telegram CLI entry point.
       7              :  *
       8              :  * Thin wrapper over tg-domain-read + tg-domain-write (ADR-0005). Exposes
       9              :  * every read command (identical to tg-cli-ro) plus all write commands.
      10              :  * For the interactive REPL/TUI use tg-tui(1).
      11              :  */
      12              : 
      13              : #include "app/bootstrap.h"
      14              : #include "app/auth_flow.h"
      15              : #include "app/credentials.h"
      16              : #include "app/config_wizard.h"
      17              : #include "app/dc_config.h"
      18              : #include "app/session_store.h"
      19              : #include "infrastructure/auth_logout.h"
      20              : #include "infrastructure/updates_state_store.h"
      21              : #include "infrastructure/media_index.h"
      22              : #include "logger.h"
      23              : #include "arg_parse.h"
      24              : #include "json_util.h"
      25              : #include "platform/path.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/search.h"
      33              : #include "domain/read/contacts.h"
      34              : #include "domain/read/media.h"
      35              : #include "domain/write/send.h"
      36              : #include "domain/write/read_history.h"
      37              : #include "domain/write/edit.h"
      38              : #include "domain/write/delete.h"
      39              : #include "domain/write/forward.h"
      40              : #include "domain/write/upload.h"
      41              : #include "fs_util.h"
      42              : 
      43              : #include <errno.h>
      44              : #include <signal.h>
      45              : #include <stdio.h>
      46              : #include <stdlib.h>
      47              : #include <string.h>
      48              : #include <time.h>
      49              : #include <unistd.h>
      50              : 
      51              : /* SEC-01: set once at startup; 1 when stdout is a real terminal. */
      52              : static int g_stdout_is_tty = 0;
      53              : 
      54            0 : static void fmt_date(char *buf, size_t cap, int64_t ts) {
      55            0 :     time_t t = (time_t)ts;
      56              :     struct tm tm;
      57            0 :     localtime_r(&t, &tm);
      58            0 :     strftime(buf, cap, "%Y-%m-%d %H:%M", &tm);
      59            0 : }
      60              : 
      61              : /**
      62              :  * @brief Sanitize @p src into @p dst for terminal display (SEC-01).
      63              :  */
      64            0 : static void tty_sanitize(char *dst, size_t cap, const char *src) {
      65            0 :     if (!dst || cap == 0) return;
      66            0 :     if (!src) { dst[0] = '\0'; return; }
      67            0 :     if (!g_stdout_is_tty) {
      68            0 :         size_t i = 0;
      69            0 :         while (src[i] && i + 1 < cap) { dst[i] = src[i]; i++; }
      70            0 :         dst[i] = '\0';
      71            0 :         return;
      72              :     }
      73            0 :     size_t i = 0;
      74            0 :     while (*src && i + 1 < cap) {
      75            0 :         unsigned char c = (unsigned char)*src++;
      76            0 :         if ((c < 0x20 && c != 0x09 && c != 0x0A) || c == 0x7F || c == 0x9B)
      77            0 :             dst[i++] = '.';
      78              :         else
      79            0 :             dst[i++] = (char)c;
      80              :     }
      81            0 :     dst[i] = '\0';
      82              : }
      83              : 
      84              : typedef struct {
      85              :     const char *phone;
      86              :     const char *code;
      87              :     const char *password;
      88              : } BatchCreds;
      89              : 
      90            0 : static int cb_get_phone(void *u, char *out, size_t cap) {
      91            0 :     const BatchCreds *c = (const BatchCreds *)u;
      92            0 :     if (!c->phone) {
      93            0 :         fprintf(stderr, "tg-cli: --phone <number> required in batch mode\n");
      94            0 :         return -1;
      95              :     }
      96            0 :     snprintf(out, cap, "%s", c->phone);
      97            0 :     return 0;
      98              : }
      99            0 : static int cb_get_code(void *u, char *out, size_t cap) {
     100            0 :     const BatchCreds *c = (const BatchCreds *)u;
     101            0 :     if (!c->code) {
     102            0 :         fprintf(stderr, "tg-cli: --code <digits> required in batch mode\n");
     103            0 :         return -1;
     104              :     }
     105            0 :     snprintf(out, cap, "%s", c->code);
     106            0 :     return 0;
     107              : }
     108            0 : static int cb_get_password(void *u, char *out, size_t cap) {
     109            0 :     const BatchCreds *c = (const BatchCreds *)u;
     110            0 :     if (!c->password) return -1;
     111            0 :     snprintf(out, cap, "%s", c->password);
     112            0 :     return 0;
     113              : }
     114              : 
     115            0 : static int session_bringup(const ArgResult *args, ApiConfig *cfg,
     116              :                             MtProtoSession *s, Transport *t) {
     117            0 :     if (credentials_load(cfg) != 0) return 1;
     118              :     static BatchCreds creds;
     119            0 :     creds.phone = args->phone;
     120            0 :     creds.code = args->code;
     121            0 :     creds.password = args->password;
     122              : 
     123              :     static AuthFlowCallbacks cb;
     124            0 :     cb.get_phone = cb_get_phone;
     125            0 :     cb.get_code = cb_get_code;
     126            0 :     cb.get_password = cb_get_password;
     127            0 :     cb.user = &creds;
     128              : 
     129            0 :     transport_init(t);
     130            0 :     mtproto_session_init(s);
     131            0 :     AuthFlowResult res = {0};
     132            0 :     if (auth_flow_login(cfg, &cb, t, s, &res) != 0) {
     133            0 :         fprintf(stderr, "tg-cli: login failed (see logs)\n");
     134            0 :         transport_close(t);
     135            0 :         return 1;
     136              :     }
     137            0 :     return 0;
     138              : }
     139              : 
     140              : /* ---- Read-command helpers (matching tg_cli_ro) ---- */
     141              : 
     142            0 : static const char *peer_kind_name(DialogPeerKind k) {
     143            0 :     switch (k) {
     144            0 :     case DIALOG_PEER_USER:    return "user";
     145            0 :     case DIALOG_PEER_CHAT:    return "chat";
     146            0 :     case DIALOG_PEER_CHANNEL: return "channel";
     147            0 :     default:                  return "unknown";
     148              :     }
     149              : }
     150              : 
     151            0 : static const char *resolved_kind_name(ResolvedKind k) {
     152            0 :     switch (k) {
     153            0 :     case RESOLVED_KIND_USER:    return "user";
     154            0 :     case RESOLVED_KIND_CHAT:    return "chat";
     155            0 :     case RESOLVED_KIND_CHANNEL: return "channel";
     156            0 :     default:                    return "unknown";
     157              :     }
     158              : }
     159              : 
     160              : static volatile sig_atomic_t g_stop = 0;
     161            0 : static void on_sigint(int sig) { (void)sig; g_stop = 1; }
     162              : 
     163              : #define WATCH_BACKOFF_CAP_S  300
     164              : #define WATCH_BACKOFF_INIT_S   5
     165              : #define WATCH_PEERS_MAX 64
     166              : 
     167            0 : static int watch_peers_resolve(const ApiConfig *cfg, MtProtoSession *s,
     168              :                                 Transport *t, const char *spec,
     169              :                                 int64_t *ids, int cap) {
     170            0 :     if (!spec || !*spec) return 0;
     171              :     char buf[4096];
     172            0 :     size_t slen = strlen(spec);
     173            0 :     if (slen >= sizeof(buf)) {
     174            0 :         fprintf(stderr, "watch: --peers value too long\n");
     175            0 :         return -1;
     176              :     }
     177            0 :     memcpy(buf, spec, slen + 1);
     178            0 :     int n = 0;
     179            0 :     char *tok = strtok(buf, ",");
     180            0 :     while (tok && n < cap) {
     181            0 :         while (*tok == ' ' || *tok == '\t') tok++;
     182            0 :         char *end = tok + strlen(tok);
     183            0 :         while (end > tok && (end[-1] == ' ' || end[-1] == '\t')) *--end = '\0';
     184            0 :         if (!*tok) { tok = strtok(NULL, ","); continue; }
     185            0 :         if (strcmp(tok, "self") == 0) { ids[n++] = 0; tok = strtok(NULL, ","); continue; }
     186            0 :         char *numend = NULL;
     187            0 :         long long numid = strtoll(tok, &numend, 10);
     188            0 :         if (numend && *numend == '\0') { ids[n++] = (int64_t)numid; tok = strtok(NULL, ","); continue; }
     189            0 :         ResolvedPeer rp = {0};
     190            0 :         if (domain_resolve_username(cfg, s, t, tok, &rp) != 0) {
     191            0 :             fprintf(stderr, "watch: --peers: cannot resolve '%s'\n", tok);
     192            0 :             return -1;
     193              :         }
     194            0 :         ids[n++] = rp.id;
     195            0 :         tok = strtok(NULL, ",");
     196              :     }
     197            0 :     return n;
     198              : }
     199              : 
     200            0 : static int watch_peer_allowed(const int64_t *ids, int n, int64_t peer_id) {
     201            0 :     if (n == 0) return 1;
     202            0 :     for (int i = 0; i < n; i++) if (ids[i] == peer_id) return 1;
     203            0 :     return 0;
     204              : }
     205              : 
     206              : static int resolve_peer_arg(const ApiConfig *cfg, MtProtoSession *s,
     207              :                              Transport *t, const char *peer_arg,
     208              :                              HistoryPeer *out);
     209              : 
     210              : /* Returns 1 if s looks like a decimal integer (optionally negative). */
     211            0 : static int is_numeric_id(const char *s) {
     212            0 :     if (!s || !*s) return 0;
     213            0 :     const char *p = (*s == '-') ? s + 1 : s;
     214            0 :     if (!*p) return 0;
     215            0 :     for (; *p; p++) if (*p < '0' || *p > '9') return 0;
     216            0 :     return 1;
     217              : }
     218              : 
     219              : /* Resolve a numeric peer_id via the dialogs cache.
     220              :  * If the cache is cold, fetches the inbox first to populate it. */
     221            0 : static int resolve_numeric_peer(const ApiConfig *cfg, MtProtoSession *s,
     222              :                                   Transport *t, int64_t peer_id,
     223              :                                   HistoryPeer *out) {
     224            0 :     DialogEntry de = {0};
     225            0 :     if (domain_dialogs_find_by_id(peer_id, &de) != 0) {
     226              :         DialogEntry tmp[200];
     227            0 :         int tc = 0;
     228            0 :         domain_get_dialogs(cfg, s, t, 200, 0, tmp, &tc, NULL);
     229            0 :         if (domain_dialogs_find_by_id(peer_id, &de) != 0) return -1;
     230              :     }
     231            0 :     switch (de.kind) {
     232            0 :     case DIALOG_PEER_USER:    out->kind = HISTORY_PEER_USER;    break;
     233            0 :     case DIALOG_PEER_CHAT:    out->kind = HISTORY_PEER_CHAT;    break;
     234            0 :     case DIALOG_PEER_CHANNEL: out->kind = HISTORY_PEER_CHANNEL; break;
     235            0 :     default: return -1;
     236              :     }
     237            0 :     out->peer_id     = de.peer_id;
     238            0 :     out->access_hash = de.access_hash;
     239            0 :     return 0;
     240              : }
     241              : 
     242            0 : static int cmd_me(const ArgResult *args) {
     243              :     ApiConfig cfg; MtProtoSession s; Transport t;
     244            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     245            0 :     if (brc != 0) return brc;
     246            0 :     SelfInfo me = {0};
     247            0 :     int rc = domain_get_self(&cfg, &s, &t, &me);
     248            0 :     transport_close(&t);
     249            0 :     if (rc != 0) { fprintf(stderr, "tg-cli me: failed\n"); return 1; }
     250            0 :     if (args->json) {
     251            0 :         printf("{\"id\":%lld,\"username\":\"%s\",\"first_name\":\"%s\","
     252              :                "\"last_name\":\"%s\",\"phone\":\"%s\",\"premium\":%s,\"bot\":%s}\n",
     253            0 :                (long long)me.id, me.username, me.first_name, me.last_name, me.phone,
     254            0 :                me.is_premium ? "true" : "false", me.is_bot ? "true" : "false");
     255              :     } else {
     256              :         char s1[128], s2[128], s3[128];
     257            0 :         tty_sanitize(s1, sizeof(s1), me.username);
     258            0 :         tty_sanitize(s2, sizeof(s2), me.first_name);
     259            0 :         tty_sanitize(s3, sizeof(s3), me.last_name);
     260            0 :         printf("id:       %lld\n", (long long)me.id);
     261            0 :         if (me.username[0])   printf("username: @%s\n", s1);
     262            0 :         if (me.first_name[0] || me.last_name[0])
     263            0 :             printf("name:     %s%s%s\n", s2, me.last_name[0] ? " " : "", s3);
     264            0 :         if (me.phone[0])      printf("phone:    +%s\n", me.phone);
     265            0 :         printf("premium:  %s\n", me.is_premium ? "yes" : "no");
     266            0 :         if (me.is_bot)        printf("bot:      yes\n");
     267              :     }
     268            0 :     return 0;
     269              : }
     270              : 
     271            0 : static int cmd_dialogs(const ArgResult *args) {
     272              :     ApiConfig cfg; MtProtoSession s; Transport t;
     273            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     274            0 :     if (brc != 0) return brc;
     275            0 :     int limit = args->limit > 0 ? args->limit : 20;
     276            0 :     if (limit > 100) limit = 100;
     277            0 :     DialogEntry *entries = calloc((size_t)limit, sizeof(DialogEntry));
     278            0 :     if (!entries) { transport_close(&t); return 1; }
     279            0 :     int count = 0;
     280            0 :     int rc = domain_get_dialogs(&cfg, &s, &t, limit, args->archived, entries, &count, NULL);
     281            0 :     transport_close(&t);
     282            0 :     if (rc != 0) {
     283            0 :         fprintf(stderr, "tg-cli dialogs: failed (see logs)\n");
     284            0 :         free(entries); return 1;
     285              :     }
     286            0 :     if (args->json) {
     287            0 :         printf("[");
     288            0 :         for (int i = 0; i < count; i++) {
     289            0 :             if (i) printf(",");
     290            0 :             printf("{\"type\":\"%s\",\"id\":%lld,\"title\":\"%s\","
     291              :                    "\"username\":\"%s\",\"top\":%d,\"unread\":%d}",
     292            0 :                    peer_kind_name(entries[i].kind),
     293            0 :                    (long long)entries[i].peer_id,
     294            0 :                    entries[i].title, entries[i].username,
     295            0 :                    entries[i].top_message_id, entries[i].unread_count);
     296              :         }
     297            0 :         printf("]\n");
     298              :     } else {
     299            0 :         printf("%-8s %6s %-32s %s\n", "type", "unread", "title", "@username / id");
     300            0 :         for (int i = 0; i < count; i++) {
     301              :             char stitle[128], susername[64];
     302            0 :             tty_sanitize(stitle, sizeof(stitle), entries[i].title);
     303            0 :             tty_sanitize(susername, sizeof(susername), entries[i].username);
     304            0 :             const char *title = entries[i].title[0] ? stitle : "(no title)";
     305            0 :             if (entries[i].username[0])
     306            0 :                 printf("%-8s %6d %-32s @%s\n",
     307            0 :                        peer_kind_name(entries[i].kind), entries[i].unread_count,
     308              :                        title, susername);
     309              :             else
     310            0 :                 printf("%-8s %6d %-32s %lld\n",
     311            0 :                        peer_kind_name(entries[i].kind), entries[i].unread_count,
     312            0 :                        title, (long long)entries[i].peer_id);
     313              :         }
     314              :     }
     315            0 :     free(entries);
     316            0 :     return 0;
     317              : }
     318              : 
     319            0 : static int cmd_history(const ArgResult *args) {
     320              :     ApiConfig cfg; MtProtoSession s; Transport t;
     321            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     322            0 :     if (brc != 0) return brc;
     323            0 :     HistoryPeer peer = {0};
     324            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     325            0 :         transport_close(&t); return 1;
     326              :     }
     327            0 :     int limit = args->limit > 0 ? args->limit : 20;
     328            0 :     if (limit > 100) limit = 100;
     329            0 :     int offset = args->offset > 0 ? args->offset : 0;
     330            0 :     HistoryEntry *entries = calloc((size_t)limit, sizeof(HistoryEntry));
     331            0 :     if (!entries) { transport_close(&t); return 1; }
     332            0 :     int count = 0;
     333            0 :     int rc = domain_get_history(&cfg, &s, &t, &peer, offset, limit, entries, &count);
     334            0 :     transport_close(&t);
     335            0 :     if (rc != 0) {
     336            0 :         fprintf(stderr, "tg-cli history: failed (see logs)\n");
     337            0 :         free(entries); return 1;
     338              :     }
     339              :     static const char *media_label[] = {
     340              :         [MEDIA_NONE] = "", [MEDIA_EMPTY] = "", [MEDIA_UNSUPPORTED] = "unsup",
     341              :         [MEDIA_PHOTO] = "photo", [MEDIA_DOCUMENT] = "document", [MEDIA_GEO] = "geo",
     342              :         [MEDIA_CONTACT] = "contact", [MEDIA_VENUE] = "venue",
     343              :         [MEDIA_GEO_LIVE] = "geo_live", [MEDIA_DICE] = "dice",
     344              :         [MEDIA_WEBPAGE] = "webpage", [MEDIA_POLL] = "poll",
     345              :         [MEDIA_INVOICE] = "invoice", [MEDIA_STORY] = "story",
     346              :         [MEDIA_GIVEAWAY] = "giveaway", [MEDIA_GAME] = "game",
     347              :         [MEDIA_PAID] = "paid", [MEDIA_OTHER] = "other",
     348              :     };
     349            0 :     if (args->json) {
     350            0 :         printf("[");
     351            0 :         int first = 1;
     352            0 :         for (int i = 0; i < count; i++) {
     353            0 :             if (args->no_media && entries[i].media != MEDIA_NONE
     354            0 :                     && entries[i].media != MEDIA_EMPTY
     355            0 :                     && entries[i].text[0] == '\0') continue;
     356            0 :             if (!first) printf(",");
     357            0 :             first = 0;
     358            0 :             const char *ml = args->no_media ? "" : media_label[entries[i].media];
     359            0 :             long long mid = args->no_media ? 0LL : (long long)entries[i].media_id;
     360            0 :             char cached_path[2048] = {0};
     361            0 :             int has_cache = 0;
     362            0 :             if (!args->no_media && entries[i].media_id != 0
     363            0 :                 && (entries[i].media == MEDIA_PHOTO || entries[i].media == MEDIA_DOCUMENT))
     364            0 :                 has_cache = (media_index_get(entries[i].media_id, cached_path, sizeof(cached_path)) == 1);
     365            0 :             printf("{\"id\":%d,\"out\":%s,\"date\":%lld,\"text\":\"%s\","
     366              :                    "\"complex\":%s,\"media\":\"%s\",\"media_id\":%lld"
     367              :                    ",\"media_path\":\"%s\"}",
     368            0 :                    entries[i].id, entries[i].out ? "true" : "false",
     369            0 :                    (long long)entries[i].date, entries[i].text,
     370            0 :                    entries[i].complex ? "true" : "false",
     371              :                    ml, mid, has_cache ? cached_path : "");
     372              :         }
     373            0 :         printf("]\n");
     374              :     } else {
     375            0 :         int printed = 0;
     376            0 :         for (int i = 0; i < count; i++) {
     377            0 :             const char *ml = media_label[entries[i].media];
     378            0 :             char cached_path[2048] = {0};
     379            0 :             int has_cache = 0;
     380            0 :             if (entries[i].media_id != 0
     381            0 :                 && (entries[i].media == MEDIA_PHOTO || entries[i].media == MEDIA_DOCUMENT))
     382            0 :                 has_cache = (media_index_get(entries[i].media_id, cached_path, sizeof(cached_path)) == 1);
     383              :             char stext[HISTORY_TEXT_MAX];
     384            0 :             tty_sanitize(stext, sizeof(stext), entries[i].text);
     385              :             char dstr[20];
     386            0 :             fmt_date(dstr, sizeof(dstr), entries[i].date);
     387            0 :             if (ml[0] && args->no_media) {
     388            0 :                 if (entries[i].text[0] == '\0') continue;
     389            0 :                 printf("[%d] %s %s %s\n",
     390            0 :                        entries[i].id, entries[i].out ? ">" : "<", dstr, stext);
     391            0 :                 printed++;
     392            0 :             } else if (ml[0]) {
     393            0 :                 if (has_cache)
     394            0 :                     printf("[%d] %s %s [%s: %s] %s\n",
     395            0 :                            entries[i].id, entries[i].out ? ">" : "<",
     396              :                            dstr, ml, cached_path, stext);
     397              :                 else
     398            0 :                     printf("[%d] %s %s [%s] %s\n",
     399            0 :                            entries[i].id, entries[i].out ? ">" : "<",
     400              :                            dstr, ml, stext);
     401            0 :                 printed++;
     402              :             } else {
     403            0 :                 printf("[%d] %s %s %s\n",
     404            0 :                        entries[i].id, entries[i].out ? ">" : "<", dstr, stext);
     405            0 :                 printed++;
     406              :             }
     407              :         }
     408            0 :         if (printed == 0) printf("(no messages)\n");
     409              :     }
     410            0 :     free(entries);
     411            0 :     return 0;
     412              : }
     413              : 
     414            0 : static int cmd_search(const ArgResult *args) {
     415            0 :     if (!args->query) { fprintf(stderr, "tg-cli search: <query> required\n"); return 1; }
     416              :     ApiConfig cfg; MtProtoSession s; Transport t;
     417            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     418            0 :     if (brc != 0) return brc;
     419            0 :     int limit = args->limit > 0 ? args->limit : 20;
     420            0 :     if (limit > 100) limit = 100;
     421            0 :     HistoryEntry *entries = calloc((size_t)limit, sizeof(HistoryEntry));
     422            0 :     if (!entries) { transport_close(&t); return 1; }
     423            0 :     int count = 0, rc;
     424            0 :     if (args->peer) {
     425            0 :         HistoryPeer peer = {0};
     426            0 :         if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     427            0 :             transport_close(&t); free(entries); return 1;
     428              :         }
     429            0 :         rc = domain_search_peer(&cfg, &s, &t, &peer, args->query, limit, entries, &count);
     430              :     } else {
     431            0 :         rc = domain_search_global(&cfg, &s, &t, args->query, limit, entries, &count);
     432              :     }
     433            0 :     transport_close(&t);
     434            0 :     if (rc != 0) {
     435            0 :         fprintf(stderr, "tg-cli search: failed (see logs)\n");
     436            0 :         free(entries); return 1;
     437              :     }
     438            0 :     if (args->json) {
     439              :         char esc[HISTORY_TEXT_MAX * 6 + 1];
     440            0 :         printf("[");
     441            0 :         for (int i = 0; i < count; i++) {
     442            0 :             if (i) printf(",");
     443            0 :             json_escape_str(esc, sizeof(esc), entries[i].text);
     444            0 :             printf("{\"id\":%d,\"out\":%s,\"date\":%lld,\"text\":\"%s\","
     445              :                    "\"complex\":%s}",
     446            0 :                    entries[i].id, entries[i].out ? "true" : "false",
     447            0 :                    (long long)entries[i].date, esc,
     448            0 :                    entries[i].complex ? "true" : "false");
     449              :         }
     450            0 :         printf("]\n");
     451              :     } else {
     452            0 :         printf("%-8s %-4s %-17s %s\n", "id", "out", "date", "text");
     453            0 :         for (int i = 0; i < count; i++) {
     454              :             char stext[HISTORY_TEXT_MAX];
     455            0 :             tty_sanitize(stext, sizeof(stext), entries[i].text);
     456            0 :             char dstr[20]; fmt_date(dstr, sizeof(dstr), entries[i].date);
     457            0 :             printf("%-8d %-4s %-17s %s\n",
     458            0 :                    entries[i].id, entries[i].out ? "yes" : "no", dstr, stext);
     459              :         }
     460            0 :         if (count == 0) printf("(no matches)\n");
     461              :     }
     462            0 :     free(entries);
     463            0 :     return 0;
     464              : }
     465              : 
     466            0 : static int cmd_contacts(const ArgResult *args) {
     467              :     ApiConfig cfg; MtProtoSession s; Transport t;
     468            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     469            0 :     if (brc != 0) return brc;
     470            0 :     ContactEntry *entries = calloc(CONTACTS_MAX, sizeof(ContactEntry));
     471            0 :     if (!entries) { transport_close(&t); return 1; }
     472            0 :     int count = 0;
     473            0 :     int rc = domain_get_contacts(&cfg, &s, &t, entries, CONTACTS_MAX, &count);
     474            0 :     transport_close(&t);
     475            0 :     if (rc != 0) {
     476            0 :         fprintf(stderr, "tg-cli contacts: failed (see logs)\n");
     477            0 :         free(entries); return 1;
     478              :     }
     479            0 :     if (args->json) {
     480            0 :         printf("[");
     481            0 :         for (int i = 0; i < count; i++) {
     482            0 :             if (i) printf(",");
     483            0 :             printf("{\"user_id\":%lld,\"mutual\":%s"
     484              :                    ",\"name\":\"%s\",\"username\":\"%s\"}",
     485            0 :                    (long long)entries[i].user_id,
     486            0 :                    entries[i].mutual ? "true" : "false",
     487            0 :                    entries[i].name,
     488            0 :                    entries[i].username);
     489              :         }
     490            0 :         printf("]\n");
     491              :     } else {
     492            0 :         printf("%-12s %-30s %-20s %s\n",
     493              :                "user_id", "name", "username", "mutual");
     494            0 :         for (int i = 0; i < count; i++)
     495            0 :             printf("%-12lld %-30s %-20s %s\n",
     496            0 :                    (long long)entries[i].user_id,
     497            0 :                    entries[i].name,
     498            0 :                    entries[i].username,
     499            0 :                    entries[i].mutual ? "yes" : "no");
     500            0 :         if (count == 0) printf("(no contacts)\n");
     501              :     }
     502            0 :     free(entries);
     503            0 :     return 0;
     504              : }
     505              : 
     506            0 : static int cmd_user_info(const ArgResult *args) {
     507            0 :     if (!args->peer) { fprintf(stderr, "tg-cli user-info: <peer> required\n"); return 1; }
     508              :     ApiConfig cfg; MtProtoSession s; Transport t;
     509            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     510            0 :     if (brc != 0) return brc;
     511            0 :     ResolvedPeer r = {0};
     512            0 :     int rc = domain_resolve_username(&cfg, &s, &t, args->peer, &r);
     513            0 :     transport_close(&t);
     514            0 :     if (rc != 0) { fprintf(stderr, "tg-cli user-info: resolve failed\n"); return 1; }
     515            0 :     if (args->json) {
     516            0 :         printf("{\"type\":\"%s\",\"id\":%lld,\"name\":\"%s\","
     517              :                "\"username\":\"%s\",\"access_hash\":\"%s\"}\n",
     518            0 :                resolved_kind_name(r.kind), (long long)r.id,
     519            0 :                r.title, r.username, r.have_hash ? "present" : "none");
     520              :     } else {
     521              :         char su[64], st[128];
     522            0 :         tty_sanitize(su, sizeof(su), r.username);
     523            0 :         tty_sanitize(st, sizeof(st), r.title);
     524            0 :         printf("type:         %s\n", resolved_kind_name(r.kind));
     525            0 :         printf("id:           %lld\n", (long long)r.id);
     526            0 :         if (r.title[0])    printf("name:         %s\n", st);
     527            0 :         if (r.username[0]) printf("username:     @%s\n", su);
     528            0 :         printf("access_hash:  %s\n", r.have_hash ? "present" : "none");
     529              :     }
     530            0 :     return 0;
     531              : }
     532              : 
     533            0 : static int cmd_watch(const ArgResult *args) {
     534              :     ApiConfig cfg; MtProtoSession s; Transport t;
     535            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     536            0 :     if (brc != 0) return brc;
     537            0 :     signal(SIGINT, on_sigint);
     538            0 :     signal(SIGPIPE, SIG_IGN);
     539              :     int64_t peer_filter[WATCH_PEERS_MAX];
     540            0 :     int peer_filter_n = 0;
     541            0 :     if (args->watch_peers) {
     542            0 :         peer_filter_n = watch_peers_resolve(&cfg, &s, &t, args->watch_peers,
     543              :                                             peer_filter, WATCH_PEERS_MAX);
     544            0 :         if (peer_filter_n < 0) { transport_close(&t); return 1; }
     545            0 :         if (!args->quiet)
     546            0 :             fprintf(stderr, "watch: filtering to %d peer(s)\n", peer_filter_n);
     547              :     }
     548            0 :     UpdatesState state = {0};
     549            0 :     int loaded = updates_state_load(&state);
     550            0 :     if (loaded != 0) {
     551            0 :         if (!args->quiet) fprintf(stderr, "watch: no persisted state, fetching from server\n");
     552            0 :         if (domain_updates_state(&cfg, &s, &t, &state) != 0) {
     553            0 :             fprintf(stderr, "tg-cli watch: getState failed\n");
     554            0 :             transport_close(&t); return 1;
     555              :         }
     556            0 :         updates_state_save(&state);
     557              :     }
     558            0 :     int interval = args->watch_interval > 0 ? args->watch_interval : 30;
     559            0 :     if (!args->quiet)
     560            0 :         fprintf(stderr, "watch: seeded pts=%d qts=%d date=%lld, "
     561              :                         "polling every %ds (SIGINT to quit)\n",
     562            0 :                         state.pts, state.qts, (long long)state.date, interval);
     563            0 :     int backoff = 0;
     564            0 :     while (!g_stop) {
     565            0 :         UpdatesDifference diff = {0};
     566            0 :         if (domain_updates_difference(&cfg, &s, &t, &state, &diff) != 0) {
     567            0 :             if (backoff == 0) backoff = WATCH_BACKOFF_INIT_S;
     568            0 :             else if (backoff < WATCH_BACKOFF_CAP_S)
     569            0 :                 backoff = (backoff * 2 < WATCH_BACKOFF_CAP_S) ? backoff * 2 : WATCH_BACKOFF_CAP_S;
     570            0 :             fprintf(stderr, "watch: getDifference failed, retrying in %ds\n", backoff);
     571            0 :             for (int i = 0; i < backoff && !g_stop; i++) sleep(1);
     572            0 :             continue;
     573              :         }
     574            0 :         backoff = 0;
     575            0 :         state = diff.next_state;
     576            0 :         updates_state_save(&state);
     577            0 :         if (args->json) {
     578              :             char esc[HISTORY_TEXT_MAX * 6 + 1];
     579            0 :             for (int i = 0; i < diff.new_messages_count; i++) {
     580            0 :                 if (!watch_peer_allowed(peer_filter, peer_filter_n, diff.new_messages[i].peer_id)) continue;
     581            0 :                 json_escape_str(esc, sizeof(esc), diff.new_messages[i].text);
     582            0 :                 if (printf("{\"peer_id\":%lld,\"msg_id\":%d,\"date\":%lld,\"text\":\"%s\"}\n",
     583            0 :                            (long long)diff.new_messages[i].peer_id, diff.new_messages[i].id,
     584            0 :                            (long long)diff.new_messages[i].date, esc) < 0
     585            0 :                     || fflush(stdout) != 0) {
     586            0 :                     if (errno == EPIPE) { g_stop = 1; break; }
     587              :                 }
     588              :             }
     589              :         } else {
     590            0 :             int printed = 0;
     591            0 :             for (int i = 0; i < diff.new_messages_count; i++) {
     592            0 :                 if (!watch_peer_allowed(peer_filter, peer_filter_n, diff.new_messages[i].peer_id)) continue;
     593              :                 char stext[HISTORY_TEXT_MAX];
     594            0 :                 tty_sanitize(stext, sizeof(stext), diff.new_messages[i].text);
     595            0 :                 if (printf("[%d] %lld %s\n", diff.new_messages[i].id,
     596            0 :                            (long long)diff.new_messages[i].date,
     597            0 :                            diff.new_messages[i].complex ? "(complex \xe2\x80\x94 text not parsed)" : stext) < 0) {
     598            0 :                     if (errno == EPIPE) { g_stop = 1; break; }
     599              :                 }
     600            0 :                 printed++;
     601              :             }
     602            0 :             if (!g_stop && printed == 0 && !args->quiet) {
     603            0 :                 if (printf("(no new messages; pts=%d date=%lld)\n",
     604            0 :                            state.pts, (long long)state.date) < 0 && errno == EPIPE)
     605            0 :                     g_stop = 1;
     606              :             }
     607              :         }
     608            0 :         if (!g_stop && fflush(stdout) != 0 && errno == EPIPE) g_stop = 1;
     609            0 :         for (int i = 0; i < interval && !g_stop; i++) sleep(1);
     610              :     }
     611            0 :     transport_close(&t);
     612            0 :     return 0;
     613              : }
     614              : 
     615            0 : static int cmd_download(const ArgResult *args, const AppContext *ctx) {
     616            0 :     if (!args->peer || args->msg_id <= 0) {
     617            0 :         fprintf(stderr, "tg-cli download: <peer> and positive <msg_id> required\n");
     618            0 :         return 1;
     619              :     }
     620              :     ApiConfig cfg; MtProtoSession s; Transport t;
     621            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     622            0 :     if (brc != 0) return brc;
     623            0 :     HistoryPeer peer = {0};
     624            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     625            0 :         transport_close(&t); return 1;
     626              :     }
     627            0 :     HistoryEntry entry = {0};
     628            0 :     int count = 0;
     629            0 :     int rc = domain_get_history(&cfg, &s, &t, &peer, args->msg_id + 1, 1, &entry, &count);
     630            0 :     if (rc != 0 || count == 0 || entry.id != args->msg_id) {
     631            0 :         fprintf(stderr, "tg-cli download: message %d not found\n", args->msg_id);
     632            0 :         transport_close(&t); return 1;
     633              :     }
     634            0 :     if (entry.media != MEDIA_PHOTO && entry.media != MEDIA_DOCUMENT) {
     635            0 :         fprintf(stderr, "tg-cli download: message %d has no downloadable media\n", args->msg_id);
     636            0 :         transport_close(&t); return 1;
     637              :     }
     638            0 :     if (entry.media_info.access_hash == 0 || entry.media_info.file_reference_len == 0) {
     639            0 :         fprintf(stderr, "tg-cli download: missing access_hash or file_reference\n");
     640            0 :         transport_close(&t); return 1;
     641              :     }
     642              :     char path_buf[2048];
     643            0 :     const char *out_path = args->out_path;
     644            0 :     if (!out_path) {
     645            0 :         const char *cache = ctx->cache_dir ? ctx->cache_dir : "/tmp";
     646              :         char dir_buf[1536];
     647            0 :         snprintf(dir_buf, sizeof(dir_buf), "%s/downloads", cache);
     648            0 :         fs_mkdir_p(dir_buf, 0700);
     649            0 :         if (entry.media == MEDIA_DOCUMENT) {
     650            0 :             const char *fn = entry.media_info.document_filename;
     651            0 :             if (fn[0]) snprintf(path_buf, sizeof(path_buf), "%s/%s", dir_buf, fn);
     652            0 :             else snprintf(path_buf, sizeof(path_buf), "%s/doc-%lld",
     653            0 :                           dir_buf, (long long)entry.media_info.document_id);
     654              :         } else {
     655            0 :             snprintf(path_buf, sizeof(path_buf), "%s/photo-%lld.jpg",
     656            0 :                      dir_buf, (long long)entry.media_info.photo_id);
     657              :         }
     658            0 :         out_path = path_buf;
     659              :     }
     660            0 :     rc = domain_download_media_cross_dc(&cfg, &s, &t, &entry.media_info, out_path);
     661            0 :     transport_close(&t);
     662            0 :     if (rc != 0) { fprintf(stderr, "tg-cli download: failed (see logs)\n"); return 1; }
     663            0 :     int64_t media_id = (entry.media == MEDIA_DOCUMENT)
     664            0 :                      ? entry.media_info.document_id : entry.media_info.photo_id;
     665            0 :     media_index_put(media_id, out_path);
     666            0 :     if (args->json)
     667            0 :         printf("{\"saved\":\"%s\",\"kind\":\"%s\",\"id\":%lld}\n",
     668            0 :                out_path, (entry.media == MEDIA_DOCUMENT) ? "document" : "photo",
     669              :                (long long)media_id);
     670            0 :     else if (!args->quiet)
     671            0 :         printf("saved: %s\n", out_path);
     672            0 :     return 0;
     673              : }
     674              : 
     675              : /* ---- Peer resolution (forward declaration above; definition here) ---- */
     676            0 : static int resolve_peer_arg(const ApiConfig *cfg, MtProtoSession *s,
     677              :                              Transport *t, const char *peer_arg,
     678              :                              HistoryPeer *out) {
     679            0 :     if (!peer_arg || strcmp(peer_arg, "self") == 0) {
     680            0 :         out->kind = HISTORY_PEER_SELF;
     681            0 :         return 0;
     682              :     }
     683            0 :     if (is_numeric_id(peer_arg)) {
     684            0 :         int64_t pid = (int64_t)strtoll(peer_arg, NULL, 10);
     685            0 :         return resolve_numeric_peer(cfg, s, t, pid, out);
     686              :     }
     687            0 :     ResolvedPeer rp = {0};
     688            0 :     if (domain_resolve_username(cfg, s, t, peer_arg, &rp) != 0) return -1;
     689            0 :     switch (rp.kind) {
     690            0 :     case RESOLVED_KIND_USER:    out->kind = HISTORY_PEER_USER;    break;
     691            0 :     case RESOLVED_KIND_CHANNEL: out->kind = HISTORY_PEER_CHANNEL; break;
     692            0 :     case RESOLVED_KIND_CHAT:    out->kind = HISTORY_PEER_CHAT;    break;
     693            0 :     default: return -1;
     694              :     }
     695            0 :     out->peer_id = rp.id;
     696            0 :     out->access_hash = rp.access_hash;
     697            0 :     return 0;
     698              : }
     699              : 
     700            0 : static int cmd_edit(const ArgResult *args) {
     701            0 :     if (!args->peer || args->msg_id <= 0 || !args->message) {
     702            0 :         fprintf(stderr, "tg-cli edit: <peer> <msg_id> <text> required\n");
     703            0 :         return 1;
     704              :     }
     705              :     ApiConfig cfg; MtProtoSession s; Transport t;
     706            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     707            0 :     if (brc != 0) return brc;
     708              : 
     709            0 :     HistoryPeer peer = {0};
     710            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     711            0 :         transport_close(&t); return 1;
     712              :     }
     713            0 :     RpcError err = {0};
     714            0 :     int rc = domain_edit_message(&cfg, &s, &t, &peer, args->msg_id,
     715            0 :                                    args->message, &err);
     716            0 :     transport_close(&t);
     717            0 :     if (rc != 0) {
     718            0 :         fprintf(stderr, "tg-cli edit: failed (%d: %s)\n",
     719              :                 err.error_code, err.error_msg);
     720            0 :         return 1;
     721              :     }
     722            0 :     if (args->json) printf("{\"edited\":%d}\n", args->msg_id);
     723            0 :     else if (!args->quiet) printf("edited %d\n", args->msg_id);
     724            0 :     return 0;
     725              : }
     726              : 
     727            0 : static int cmd_delete(const ArgResult *args) {
     728            0 :     if (!args->peer || args->msg_id <= 0) {
     729            0 :         fprintf(stderr, "tg-cli delete: <peer> <msg_id> required\n");
     730            0 :         return 1;
     731              :     }
     732              :     ApiConfig cfg; MtProtoSession s; Transport t;
     733            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     734            0 :     if (brc != 0) return brc;
     735              : 
     736            0 :     HistoryPeer peer = {0};
     737            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     738            0 :         transport_close(&t); return 1;
     739              :     }
     740            0 :     RpcError err = {0};
     741            0 :     int32_t ids[1] = { args->msg_id };
     742            0 :     int rc = domain_delete_messages(&cfg, &s, &t, &peer, ids, 1,
     743            0 :                                       args->revoke, &err);
     744            0 :     transport_close(&t);
     745            0 :     if (rc != 0) {
     746            0 :         fprintf(stderr, "tg-cli delete: failed (%d: %s)\n",
     747              :                 err.error_code, err.error_msg);
     748            0 :         return 1;
     749              :     }
     750            0 :     if (args->json) printf("{\"deleted\":%d,\"revoke\":%s}\n",
     751            0 :                             args->msg_id, args->revoke ? "true" : "false");
     752            0 :     else if (!args->quiet) printf("deleted %d\n", args->msg_id);
     753            0 :     return 0;
     754              : }
     755              : 
     756            0 : static int cmd_send_file(const ArgResult *args) {
     757            0 :     if (!args->peer || !args->out_path) {
     758            0 :         fprintf(stderr, "tg-cli send-file: <peer> <path> required\n");
     759            0 :         return 1;
     760              :     }
     761              :     ApiConfig cfg; MtProtoSession s; Transport t;
     762            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     763            0 :     if (brc != 0) return brc;
     764              : 
     765            0 :     HistoryPeer peer = {0};
     766            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     767            0 :         transport_close(&t); return 1;
     768              :     }
     769            0 :     RpcError err = {0};
     770            0 :     int as_photo = domain_path_is_image(args->out_path);
     771            0 :     int rc = as_photo
     772            0 :         ? domain_send_photo(&cfg, &s, &t, &peer, args->out_path,
     773            0 :                              args->message, &err)
     774            0 :         : domain_send_file (&cfg, &s, &t, &peer, args->out_path,
     775            0 :                              args->message, NULL, &err);
     776            0 :     transport_close(&t);
     777            0 :     if (rc != 0) {
     778            0 :         fprintf(stderr, "tg-cli send-file: failed (%d: %s)\n",
     779              :                 err.error_code, err.error_msg);
     780            0 :         return 1;
     781              :     }
     782            0 :     if (args->json) printf("{\"uploaded\":\"%s\",\"kind\":\"%s\"}\n",
     783            0 :                             args->out_path, as_photo ? "photo" : "document");
     784            0 :     else if (!args->quiet) printf("uploaded %s as %s\n",
     785            0 :                                    args->out_path,
     786              :                                    as_photo ? "photo" : "document");
     787            0 :     return 0;
     788              : }
     789              : 
     790            0 : static int cmd_forward(const ArgResult *args) {
     791            0 :     if (!args->peer || !args->peer2 || args->msg_id <= 0) {
     792            0 :         fprintf(stderr,
     793              :                 "tg-cli forward: <from_peer> <to_peer> <msg_id> required\n");
     794            0 :         return 1;
     795              :     }
     796              :     ApiConfig cfg; MtProtoSession s; Transport t;
     797            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     798            0 :     if (brc != 0) return brc;
     799              : 
     800            0 :     HistoryPeer from = {0}, to = {0};
     801            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &from) != 0
     802            0 :         || resolve_peer_arg(&cfg, &s, &t, args->peer2, &to) != 0) {
     803            0 :         transport_close(&t); return 1;
     804              :     }
     805            0 :     RpcError err = {0};
     806            0 :     int32_t ids[1] = { args->msg_id };
     807            0 :     int rc = domain_forward_messages(&cfg, &s, &t, &from, &to, ids, 1, &err);
     808            0 :     transport_close(&t);
     809            0 :     if (rc != 0) {
     810            0 :         fprintf(stderr, "tg-cli forward: failed (%d: %s)\n",
     811              :                 err.error_code, err.error_msg);
     812            0 :         return 1;
     813              :     }
     814            0 :     if (args->json) printf("{\"forwarded\":%d}\n", args->msg_id);
     815            0 :     else if (!args->quiet) printf("forwarded %d\n", args->msg_id);
     816            0 :     return 0;
     817              : }
     818              : 
     819            0 : static int cmd_read(const ArgResult *args) {
     820            0 :     if (!args->peer) {
     821            0 :         fprintf(stderr, "tg-cli read: <peer> required\n");
     822            0 :         return 1;
     823              :     }
     824              :     ApiConfig cfg; MtProtoSession s; Transport t;
     825            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     826            0 :     if (brc != 0) return brc;
     827              : 
     828            0 :     HistoryPeer peer = {0};
     829            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     830            0 :         fprintf(stderr, "tg-cli read: failed to resolve peer '%s'\n",
     831            0 :                 args->peer);
     832            0 :         transport_close(&t);
     833            0 :         return 1;
     834              :     }
     835            0 :     RpcError err = {0};
     836            0 :     int rc = domain_mark_read(&cfg, &s, &t, &peer, args->msg_id, &err);
     837            0 :     transport_close(&t);
     838            0 :     if (rc != 0) {
     839            0 :         fprintf(stderr, "tg-cli read: failed (%d: %s)\n",
     840              :                 err.error_code, err.error_msg);
     841            0 :         return 1;
     842              :     }
     843            0 :     if (args->json) printf("{\"read\":true}\n");
     844            0 :     else if (!args->quiet) printf("marked as read\n");
     845            0 :     return 0;
     846              : }
     847              : 
     848            0 : static int cmd_send(const ArgResult *args) {
     849            0 :     if (!args->peer) {
     850            0 :         fprintf(stderr, "tg-cli send: <peer> required\n");
     851            0 :         return 1;
     852              :     }
     853            0 :     const char *msg = args->message;
     854              :     char stdin_buf[4096];
     855              : 
     856              :     /* If no inline message and stdin is a pipe, read it (P8-03 done here). */
     857            0 :     if ((!msg || !*msg)) {
     858            0 :         if (isatty(0)) {
     859            0 :             fprintf(stderr, "tg-cli send: <message> required "
     860              :                             "(or pipe it on stdin)\n");
     861            0 :             return 1;
     862              :         }
     863            0 :         size_t n = fread(stdin_buf, 1, sizeof(stdin_buf) - 1, stdin);
     864            0 :         if (n == 0) {
     865            0 :             fprintf(stderr, "tg-cli send: empty stdin\n");
     866            0 :             return 1;
     867              :         }
     868            0 :         stdin_buf[n] = '\0';
     869              :         /* Strip one trailing newline for convenience. */
     870            0 :         if (n > 0 && stdin_buf[n - 1] == '\n') stdin_buf[n - 1] = '\0';
     871            0 :         msg = stdin_buf;
     872              :     }
     873              : 
     874              :     ApiConfig cfg; MtProtoSession s; Transport t;
     875            0 :     int brc = session_bringup(args, &cfg, &s, &t);
     876            0 :     if (brc != 0) return brc;
     877              : 
     878            0 :     HistoryPeer peer = {0};
     879            0 :     if (resolve_peer_arg(&cfg, &s, &t, args->peer, &peer) != 0) {
     880            0 :         fprintf(stderr, "tg-cli send: failed to resolve peer '%s'\n",
     881            0 :                 args->peer);
     882            0 :         transport_close(&t);
     883            0 :         return 1;
     884              :     }
     885              : 
     886            0 :     int32_t new_id = 0;
     887            0 :     RpcError err = {0};
     888            0 :     int rc = domain_send_message_reply(&cfg, &s, &t, &peer, msg,
     889            0 :                                          args->reply_to, &new_id, &err);
     890            0 :     transport_close(&t);
     891            0 :     if (rc != 0) {
     892            0 :         fprintf(stderr, "tg-cli send: failed (%d: %s)\n",
     893              :                 err.error_code, err.error_msg);
     894            0 :         return 1;
     895              :     }
     896            0 :     if (args->json) {
     897            0 :         printf("{\"sent\":true,\"message_id\":%d}\n", new_id);
     898            0 :     } else if (!args->quiet) {
     899            0 :         if (new_id > 0) printf("sent, id=%d\n", new_id);
     900            0 :         else            printf("sent\n");
     901              :     }
     902            0 :     return 0;
     903              : }
     904              : 
     905            0 : static void print_usage(void) {
     906            0 :     puts(
     907              :         "Usage: tg-cli [GLOBAL FLAGS] <subcommand> [ARGS]\n"
     908              :         "\n"
     909              :         "Batch-mode Telegram CLI — read and write. Always non-interactive.\n"
     910              :         "For the interactive REPL/TUI use tg-tui(1).\n"
     911              :         "\n"
     912              :         "Read subcommands:\n"
     913              :         "  me (or self)                         Show own profile (US-05)\n"
     914              :         "  dialogs  [--limit N] [--archived]    List dialogs (US-04)\n"
     915              :         "  history  <peer> [--limit N] [--offset N] [--no-media]  Fetch history (US-06)\n"
     916              :         "  search   [<peer>] <query> [--limit N]  Search messages (US-10)\n"
     917              :         "  contacts                             List contacts (US-09)\n"
     918              :         "  user-info <peer>                     User/channel info (US-09)\n"
     919              :         "  watch    [--peers X,Y] [--interval N]  Watch updates (US-07)\n"
     920              :         "  download <peer> <msg_id> [--out PATH]  Download photo/document (US-08)\n"
     921              :         "\n"
     922              :         "Write subcommands:\n"
     923              :         "  send <peer> [--reply N] <message>    Send a text message (US-12)\n"
     924              :         "  send <peer> --stdin                  Read message body from stdin\n"
     925              :         "  read <peer> [--max-id N]             Mark peer's history as read (US-12)\n"
     926              :         "  edit <peer> <msg_id> <text>          Edit a message (US-13)\n"
     927              :         "  delete <peer> <msg_id> [--revoke]    Delete a message (US-13)\n"
     928              :         "  forward <from> <to> <msg_id>         Forward a message (US-13)\n"
     929              :         "  send-file|upload <peer> <path> [--caption T]  Upload a file (US-14)\n"
     930              :         "\n"
     931              :         "Session:\n"
     932              :         "  login [--api-id N --api-hash HEX] [--force]  First-run config wizard\n"
     933              :         "      Interactive when stdin is a TTY. From a script, pass both\n"
     934              :         "      flags; otherwise the command exits with 1 (never blocks on input).\n"
     935              :         "  --logout                             Clear persisted session and exit\n"
     936              :         "\n"
     937              :         "Global flags:\n"
     938              :         "  --config <path>     Use non-default config file\n"
     939              :         "  --json              Emit JSON output where supported\n"
     940              :         "  --quiet             Suppress informational output\n"
     941              :         "  --help, -h          Show this help and exit\n"
     942              :         "  --version, -v       Show version and exit\n"
     943              :         "\n"
     944              :         "Login flags (for session authentication):\n"
     945              :         "  --phone <number>    E.g. +15551234567\n"
     946              :         "  --code <digits>     SMS/app code\n"
     947              :         "  --password <pass>   2FA password\n"
     948              :         "\n"
     949              :         "Credentials:\n"
     950              :         "  TG_CLI_API_ID / TG_CLI_API_HASH env vars, or\n"
     951              :         "  api_id= / api_hash= in ~/.config/tg-cli/config.ini\n"
     952              :         "  (run 'tg-cli login' to set up config.ini interactively)\n"
     953              :         "\n"
     954              :         "Examples:\n"
     955              :         "  tg-cli login --api-id 12345 --api-hash deadbeef...  # batch setup\n"
     956              :         "  tg-cli me\n"
     957              :         "  tg-cli dialogs --limit 50\n"
     958              :         "  tg-cli history @friend --limit 20\n"
     959              :         "  tg-cli send @friend 'Hello!'\n"
     960              :         "  echo 'Hi' | tg-cli send @friend\n"
     961              :         "\n"
     962              :         "Note: date fields in output are Unix epoch seconds; use 'date -d @$ts' to format.\n"
     963              :         "See man tg-cli(1) for the full reference.\n"
     964              :     );
     965            0 : }
     966              : 
     967            1 : int main(int argc, char **argv) {
     968            1 :     platform_normalize_argv(&argc, &argv);
     969            1 :     g_stdout_is_tty = isatty(STDOUT_FILENO);
     970              :     AppContext ctx;
     971            1 :     if (app_bootstrap(&ctx, "tg-cli") != 0) {
     972            1 :         fprintf(stderr, "tg-cli: bootstrap failed\n");
     973            1 :         return 1;
     974              :     }
     975              : 
     976              :     /* Drop the session-scoped resolver cache when the user logs out so a
     977              :      * subsequent login does not see stale @peer → id mappings. */
     978            0 :     auth_logout_set_cache_flush_cb(resolve_cache_flush);
     979              : 
     980              :     /* --logout: invalidate the session server-side, then wipe the local file. */
     981            0 :     for (int i = 1; i < argc; i++) {
     982            0 :         if (strcmp(argv[i], "--logout") == 0) {
     983              :             ApiConfig cfg;
     984              :             MtProtoSession s;
     985              :             Transport t;
     986            0 :             if (credentials_load(&cfg) == 0) {
     987            0 :                 transport_init(&t);
     988            0 :                 mtproto_session_init(&s);
     989            0 :                 int loaded_dc = 0;
     990            0 :                 if (session_store_load(&s, &loaded_dc) == 0) {
     991            0 :                     const DcEndpoint *ep = dc_lookup(loaded_dc);
     992            0 :                     if (ep && transport_connect(&t, ep->host, ep->port) == 0) {
     993            0 :                         t.dc_id = loaded_dc;
     994            0 :                         auth_logout(&cfg, &s, &t);
     995            0 :                         transport_close(&t);
     996              :                     } else {
     997            0 :                         logger_log(LOG_WARN,
     998              :                             "tg-cli: logout: cannot connect to DC%d, clearing local session",
     999              :                             loaded_dc);
    1000            0 :                         session_store_clear();
    1001              :                     }
    1002              :                 } else {
    1003            0 :                     session_store_clear();
    1004              :                 }
    1005              :             } else {
    1006            0 :                 session_store_clear();
    1007              :             }
    1008            0 :             fprintf(stderr, "tg-cli: persisted session cleared.\n");
    1009            0 :             app_shutdown(&ctx);
    1010            0 :             return 0;
    1011              :         }
    1012              :     }
    1013              : 
    1014              :     ArgResult args;
    1015            0 :     int rc = arg_parse(argc, argv, &args);
    1016            0 :     int exit_code = 0;
    1017              : 
    1018            0 :     switch (rc) {
    1019            0 :     case ARG_HELP:    print_usage(); break;
    1020            0 :     case ARG_VERSION: arg_print_version(); break;
    1021            0 :     case ARG_ERROR:   exit_code = 1; break;
    1022            0 :     case ARG_OK:
    1023            0 :         switch (args.command) {
    1024              :         /* Read commands */
    1025            0 :         case CMD_ME:        exit_code = cmd_me(&args); break;
    1026            0 :         case CMD_DIALOGS:   exit_code = cmd_dialogs(&args); break;
    1027            0 :         case CMD_HISTORY:   exit_code = cmd_history(&args); break;
    1028            0 :         case CMD_SEARCH:    exit_code = cmd_search(&args); break;
    1029            0 :         case CMD_CONTACTS:  exit_code = cmd_contacts(&args); break;
    1030            0 :         case CMD_USER_INFO: exit_code = cmd_user_info(&args); break;
    1031            0 :         case CMD_WATCH:     exit_code = cmd_watch(&args); break;
    1032            0 :         case CMD_DOWNLOAD:  exit_code = cmd_download(&args, &ctx); break;
    1033              :         /* Write commands */
    1034            0 :         case CMD_SEND:      exit_code = cmd_send(&args); break;
    1035            0 :         case CMD_READ:      exit_code = cmd_read(&args); break;
    1036            0 :         case CMD_EDIT:      exit_code = cmd_edit(&args); break;
    1037            0 :         case CMD_DELETE:    exit_code = cmd_delete(&args); break;
    1038            0 :         case CMD_FORWARD:   exit_code = cmd_forward(&args); break;
    1039            0 :         case CMD_SEND_FILE: exit_code = cmd_send_file(&args); break;
    1040              :         /* Session */
    1041            0 :         case CMD_LOGIN:
    1042            0 :             if (args.api_id_str || args.api_hash_str) {
    1043            0 :                 exit_code = config_wizard_run_batch(args.api_id_str,
    1044              :                                                      args.api_hash_str,
    1045            0 :                                                      args.force) != 0 ? 1 : 0;
    1046              :             } else {
    1047            0 :                 exit_code = config_wizard_run_interactive(args.force) != 0 ? 1 : 0;
    1048              :             }
    1049              :             /* Do NOT call session_bringup for login — config may not exist yet. */
    1050            0 :             app_shutdown(&ctx);
    1051            0 :             return exit_code;
    1052            0 :         case CMD_NONE:
    1053              :         default:
    1054            0 :             print_usage(); break;
    1055              :         }
    1056            0 :         break;
    1057            0 :     default:
    1058            0 :         exit_code = 1; break;
    1059              :     }
    1060              : 
    1061            0 :     app_shutdown(&ctx);
    1062            0 :     return exit_code;
    1063              : }
        

Generated by: LCOV version 2.0-1