sirc

A simple tui multi server irc client
git clone git://git.emmett1.my/sirc.git
Log | Files | Refs | README | LICENSE

sirc.c (81363B)


      1 /*
      2  * sirc.c — multi-server terminal IRC client
      3  *
      4  * Usage:   sirc [options]
      5  *
      6  * Config   (~/.sirc):
      7  *   # Default server (applied when no [server] block precedes it)
      8  *   nick      = mynick
      9  *   ignore    = badbot,spammer
     10  *
     11  *   [server]
     12  *   host      = irc.libera.chat
     13  *   port      = 6697
     14  *   nick      = mynick
     15  *   channel   = #python,#linux
     16  *   tls       = true
     17  *   sasl_user = mynick
     18  *   sasl_pass = hunter2
     19  *
     20  *   [server]
     21  *   host      = irc.oftc.net
     22  *   port      = 6697
     23  *   tls       = true
     24  *   channel   = #debian
     25  *
     26  * CLI adds one server on top of whatever the config defines:
     27  *   --host / --port / --nick / --channel / --tls / --no-tls
     28  *   --sasl-user / --sasl-pass / --config
     29  *
     30  * Commands:
     31  *   /join #chan          join channel (on current server)
     32  *   /part [#chan]        leave channel
     33  *   /cycle [#chan]       part and rejoin
     34  *   /nick NAME           change nick
     35  *   /msg NICK TEXT       private message
     36  *   /me TEXT             action
     37  *   /notice TGT TEXT     send notice
     38  *   /ctcp NICK CMD       CTCP request
     39  *   /names [#chan]       list users
     40  *   /topic [text]        get/set topic
     41  *   /whois NICK          whois lookup
     42  *   /who [target]        who query
     43  *   /list [pattern]      list channels on server
     44  *   /away [msg]          set away
     45  *   /back                clear away
     46  *   /invite NICK [chan]  invite to channel
     47  *   /kick NICK [reason]  kick from channel
     48  *   /mode [target] [m]   get/set mode
     49  *   /server HOST [port]  connect to new server
     50  *   /raw <line>          send raw IRC line (alias /quote)
     51  *   /ignore [nick]       ignore nick (no arg = list)
     52  *   /unignore NICK       unignore nick
     53  *   /clear               clear scrollback
     54  *   /connect             reconnect current server
     55  *   /quit [msg]          disconnect and exit
     56  *   /help                show help
     57  *
     58  * Keys:
     59  *   Tab          nick completion (cycle)
     60  *   Ctrl+N/P     next/prev channel (skips server headers)
     61  *   PgUp/PgDn    scroll chat
     62  *   Ctrl+W       delete word left
     63  *   Up/Down      input history
     64  */
     65 
     66 #define _POSIX_C_SOURCE 200809L
     67 #define _DEFAULT_SOURCE
     68 #define _GNU_SOURCE
     69 
     70 #include <string.h>
     71 #include <stdio.h>
     72 #include <stdlib.h>
     73 #include <stdarg.h>
     74 #include <ctype.h>
     75 #include <time.h>
     76 #include <errno.h>
     77 #include <unistd.h>
     78 #include <fcntl.h>
     79 #include <sys/socket.h>
     80 #include <netdb.h>
     81 #include <pthread.h>
     82 #include <signal.h>
     83 #include <netinet/in.h>
     84 #include <arpa/inet.h>
     85 #include <ncurses.h>
     86 #include <openssl/ssl.h>
     87 #include <openssl/err.h>
     88 
     89 /* ── constants ─────────────────────────────────────────────────────────────── */
     90 
     91 #define MAX_NICK         64
     92 #define MAX_CHAN         64
     93 #define MAX_HOST         256
     94 #define MAX_LINE         512
     95 #define MAX_INPUT        480
     96 #define MAX_HISTORY      500
     97 #define MAX_SERVERS      8
     98 #define MAX_CHANS_TOTAL  128   /* across all servers */
     99 #define MAX_USERS        512
    100 #define MAX_IGNORE       64
    101 #define MAX_HIST_LINES   256
    102 #define MAX_AUTOJOIN     16
    103 #define PING_INTERVAL    90
    104 #define CHAN_W           18
    105 #define USER_W           16
    106 #define RECONNECT_DELAY  5
    107 
    108 /* colour pair ids */
    109 #define C_BORDER        1
    110 #define C_HEADER        2
    111 #define C_ME            4
    112 #define C_JOIN          5
    113 #define C_MENTION       6
    114 #define C_STATUS        7
    115 #define C_INPUT         8
    116 #define C_CHAN_SEL      9
    117 #define C_CHAN          10
    118 #define C_UNREAD        11
    119 #define C_MENTION_CHAN  12
    120 #define C_URL           13
    121 #define C_ACTION        14
    122 #define C_SERVER_HDR    15   /* server name row in channel list */
    123 #define C_NICK_BASE     16
    124 #define C_NICK_COUNT    8
    125 
    126 /* line flags */
    127 #define F_MSG     0
    128 #define F_ME      1
    129 #define F_MENTION 2
    130 #define F_JOIN    3
    131 #define F_STATUS  4
    132 #define F_ACTION  5
    133 
    134 /* ── data structures ───────────────────────────────────────────────────────── */
    135 
    136 typedef struct {
    137     char ts[8];
    138     int  flag;
    139     char text[MAX_LINE];
    140 } Line;
    141 
    142 typedef struct {
    143     char  name[MAX_CHAN];
    144     int   srv;             /* index of owning server */
    145     Line  lines[MAX_HISTORY];
    146     int   line_head;
    147     int   line_count;
    148     char  users[MAX_USERS][MAX_NICK];
    149     int   user_count;
    150     int   unread;
    151     int   mention;
    152     int   scroll;
    153     char  topic[MAX_LINE];
    154     int   names_pending;  /* 1 while receiving fresh 353 NAMES reply */
    155 } Channel;
    156 
    157 typedef struct {
    158     char text[MAX_INPUT];
    159 } HistEntry;
    160 
    161 /* IRC event types — each carries a server index so the UI routes correctly */
    162 typedef enum {
    163     EV_CONNECTED, EV_STATUS, EV_ERROR, EV_SERVER_TEXT,
    164     EV_PRIVMSG, EV_JOIN, EV_PART, EV_QUIT_MSG,
    165     EV_NICK_CHANGE, EV_NAMES, EV_KICK, EV_RAW,
    166     EV_RECONNECT, EV_TOPIC, EV_NAMES_END
    167 } EvType;
    168 
    169 typedef struct {
    170     EvType type;
    171     int    srv;            /* server index */
    172     char   nick[MAX_NICK];
    173     char   chan[MAX_CHAN];
    174     char   text[MAX_LINE];
    175     char   extra[MAX_NICK];
    176 } Event;
    177 
    178 /* per-server state */
    179 typedef struct {
    180     /* config */
    181     char  host[MAX_HOST];
    182     int   port;
    183     char  nick[MAX_NICK];
    184     char  autojoin[MAX_AUTOJOIN][MAX_CHAN];
    185     int   autojoin_count;
    186     int   use_tls;
    187     char  sasl_user[MAX_NICK];
    188     char  sasl_pass[256];
    189 
    190     /* runtime */
    191     int   connected;
    192     volatile int net_stop;
    193     pthread_t    net_tid;
    194     pthread_mutex_t send_lock;
    195     int   sock;
    196     SSL  *ssl;
    197 
    198     int   reconnect_pending;
    199     pthread_t reconnect_tid;
    200     int   sasl_auth_sent;  /* guard: only send AUTHENTICATE once per session */
    201 
    202     /* per-server event pipe */
    203     int   evpipe[2];
    204 } Server;
    205 
    206 /* ── globals ───────────────────────────────────────────────────────────────── */
    207 
    208 static Server   g_srv[MAX_SERVERS];
    209 static int      g_srv_count  = 0;
    210 static SSL_CTX *g_ssl_ctx    = NULL;
    211 
    212 /* channels (flat array, each has a .srv index) */
    213 static Channel  g_chans[MAX_CHANS_TOTAL];
    214 static int      g_chan_count = 0;
    215 static int      g_active     = 0;   /* index into g_chans; -1 = no channels */
    216 
    217 /* global ignore list */
    218 static char  g_ignore[MAX_IGNORE][MAX_NICK];
    219 static int   g_ignore_count = 0;
    220 
    221 /* input */
    222 static char  g_input[MAX_INPUT];
    223 static int   g_input_len    = 0;
    224 static int   g_input_cur    = 0;
    225 static HistEntry g_hist[MAX_HIST_LINES];
    226 static int   g_hist_count   = 0;
    227 static int   g_hist_pos     = -1;
    228 
    229 /* tab completion */
    230 static char  g_tab_matches[MAX_USERS][MAX_NICK];
    231 static int   g_tab_count      = 0;
    232 static int   g_tab_idx        = 0;
    233 static int   g_tab_word_start = 0;
    234 static int   g_tab_active     = 0;
    235 
    236 /* single event pipe the UI polls — all servers write here */
    237 static int g_evpipe[2];
    238 
    239 /* curses windows */
    240 static WINDOW *win_chan   = NULL;
    241 static WINDOW *win_chat   = NULL;
    242 static WINDOW *win_users  = NULL;
    243 static WINDOW *win_input  = NULL;
    244 static WINDOW *win_status = NULL;
    245 
    246 /* ── utility ───────────────────────────────────────────────────────────────── */
    247 
    248 static void str_upper(char *dst, const char *src, int n) {
    249     int i;
    250     for (i = 0; i < n-1 && src[i]; i++)
    251         dst[i] = toupper((unsigned char)src[i]);
    252     dst[i] = '\0';
    253 }
    254 
    255 static int str_icase_starts(const char *hay, const char *needle) {
    256     while (*needle) {
    257         if (tolower((unsigned char)*hay) != tolower((unsigned char)*needle))
    258             return 0;
    259         hay++; needle++;
    260     }
    261     return 1;
    262 }
    263 
    264 static int nick_colour(const char *nick) {
    265     unsigned int h = 0;
    266     for (; *nick; nick++) h = (h * 31 + (unsigned char)*nick) & 0xFFFF;
    267     return C_NICK_BASE + (h % C_NICK_COUNT);
    268 }
    269 
    270 static void timestamp(char *buf, int n) {
    271     time_t t = time(NULL);
    272     strftime(buf, n, "%H:%M", localtime(&t));
    273 }
    274 
    275 /* ── channel management ────────────────────────────────────────────────────── */
    276 
    277 static int chan_find(int srv, const char *name) {
    278     for (int i = 0; i < g_chan_count; i++)
    279         if (g_chans[i].srv == srv && strcasecmp(g_chans[i].name, name) == 0)
    280             return i;
    281     return -1;
    282 }
    283 
    284 static int chan_add(int srv, const char *name) {
    285     int i = chan_find(srv, name);
    286     if (i >= 0) return i;
    287     if (g_chan_count >= MAX_CHANS_TOTAL) return 0;
    288     i = g_chan_count++;
    289     memset(&g_chans[i], 0, sizeof(Channel));
    290     strncpy(g_chans[i].name, name, MAX_CHAN-1);
    291     g_chans[i].srv = srv;
    292     return i;
    293 }
    294 
    295 static void chan_remove(int idx) {
    296     if (idx < 0 || idx >= g_chan_count) return;
    297     for (int i = idx; i < g_chan_count-1; i++)
    298         g_chans[i] = g_chans[i+1];
    299     g_chan_count--;
    300     if (g_active >= g_chan_count) g_active = g_chan_count - 1;
    301     if (g_active < 0) g_active = 0;
    302 }
    303 
    304 static Line *chan_line(Channel *ch, int idx) {
    305     if (idx < 0 || idx >= ch->line_count) return NULL;
    306     int real = (ch->line_head - ch->line_count + idx + MAX_HISTORY) % MAX_HISTORY;
    307     return &ch->lines[real];
    308 }
    309 
    310 /* strip IRC formatting: \x02 bold, \x03 colour, \x0f reset,
    311    \x1d italic, \x1f underline, \x1e strikethrough, \x11 monospace */
    312 static void strip_irc_fmt(const char *in, char *out, int outlen) {
    313     int j = 0;
    314     for (int i = 0; in[i] && j < outlen-1; i++) {
    315         unsigned char c = (unsigned char)in[i];
    316         if (c==0x02||c==0x0f||c==0x1d||c==0x1f||c==0x1e||c==0x11)
    317             continue;
    318         if (c == 0x03) {          /* colour: \x03[fg[,bg]] — skip digits */
    319             i++;
    320             if (in[i] && isdigit((unsigned char)in[i])) i++;
    321             if (in[i] && isdigit((unsigned char)in[i])) i++;
    322             if (in[i] == ',') {
    323                 i++;
    324                 if (in[i] && isdigit((unsigned char)in[i])) i++;
    325                 if (in[i] && isdigit((unsigned char)in[i])) i++;
    326             }
    327             i--;
    328             continue;
    329         }
    330         out[j++] = (char)c;
    331     }
    332     out[j] = '\0';
    333 }
    334 
    335 static void chan_addline(Channel *ch, int flag, const char *text) {
    336     char ts[8]; timestamp(ts, sizeof(ts));
    337     Line *l = &ch->lines[ch->line_head % MAX_HISTORY];
    338     strncpy(l->ts, ts, sizeof(l->ts)-1);
    339     strip_irc_fmt(text, l->text, MAX_LINE);
    340     l->flag = flag;
    341     ch->line_head = (ch->line_head + 1) % MAX_HISTORY;
    342     if (ch->line_count < MAX_HISTORY) ch->line_count++;
    343     if (flag != F_STATUS) ch->unread++;
    344 }
    345 
    346 /* get the status channel for a server (create if needed) */
    347 static int srv_status_chan(int srv) {
    348     return chan_add(srv, "*status*");
    349 }
    350 
    351 static void srv_status_msg(int srv, const char *msg) {
    352     int ci = srv_status_chan(srv);
    353     chan_addline(&g_chans[ci], F_STATUS, msg);
    354 }
    355 
    356 static void srv_status_fmt(int srv, const char *fmt, ...) {
    357     char buf[MAX_LINE];
    358     va_list ap; va_start(ap, fmt);
    359     vsnprintf(buf, sizeof(buf), fmt, ap);
    360     va_end(ap);
    361     srv_status_msg(srv, buf);
    362 }
    363 
    364 /* Store user as "<prefix><nick>" where prefix is @, +, ~, &, % or space.
    365    Lookup is always by bare nick (skip leading mode char). */
    366 static int user_find(Channel *ch, const char *nick) {
    367     for (int i = 0; i < ch->user_count; i++) {
    368         const char *stored = ch->users[i];
    369         /* skip stored mode prefix */
    370         if (*stored == '@' || *stored == '+' || *stored == '~' ||
    371             *stored == '&' || *stored == '%' || *stored == ' ')
    372             stored++;
    373         if (strcasecmp(stored, nick) == 0) return i;
    374     }
    375     return -1;
    376 }
    377 
    378 static void chan_adduser(Channel *ch, char mode, const char *nick) {
    379     int idx = user_find(ch, nick);
    380     char entry[MAX_NICK];
    381     entry[0] = mode ? mode : ' ';
    382     strncpy(entry+1, nick, MAX_NICK-2);
    383     if (idx >= 0) {
    384         /* update mode in place */
    385         strncpy(ch->users[idx], entry, MAX_NICK-1);
    386         return;
    387     }
    388     if (ch->user_count >= MAX_USERS) return;
    389     strncpy(ch->users[ch->user_count++], entry, MAX_NICK-1);
    390 }
    391 
    392 static void chan_removeuser(Channel *ch, const char *nick) {
    393     int idx = user_find(ch, nick);
    394     if (idx < 0) return;
    395     for (int j = idx; j < ch->user_count-1; j++)
    396         memcpy(ch->users[j], ch->users[j+1], MAX_NICK);
    397     ch->user_count--;
    398 }
    399 
    400 static int mode_rank(char c) {
    401     /* lower = higher privilege */
    402     switch (c) {
    403         case '~': return 0;   /* founder/owner */
    404         case '&': return 1;   /* protected */
    405         case '@': return 2;   /* op */
    406         case '%': return 3;   /* halfop */
    407         case '+': return 4;   /* voice */
    408         default:  return 5;   /* regular */
    409     }
    410 }
    411 
    412 static int user_cmp(const void *a, const void *b) {
    413     const char *sa = (const char *)a;
    414     const char *sb = (const char *)b;
    415     int ra = mode_rank(*sa);
    416     int rb = mode_rank(*sb);
    417     if (ra != rb) return ra - rb;
    418     /* same rank: sort by nick (skip mode prefix) */
    419     const char *na = (*sa == ' ' || mode_rank(*sa) < 5) ? sa+1 : sa;
    420     const char *nb = (*sb == ' ' || mode_rank(*sb) < 5) ? sb+1 : sb;
    421     return strcasecmp(na, nb);
    422 }
    423 
    424 static void chan_sort_users(Channel *ch) {
    425     qsort(ch->users, ch->user_count, MAX_NICK, user_cmp);
    426 }
    427 
    428 /* next/prev channel index that is NOT a *status* channel (unless it's the
    429    only kind), skipping nothing — just a linear wrap */
    430 /* Build the visual order of channels (same traversal as draw_channels).
    431    Returns the count; fills idx[] with g_chans indices in display order. */
    432 static int visual_order(int *idx, int max) {
    433     int n = 0;
    434     for (int si = 0; si < g_srv_count && n < max; si++) {
    435         /* *status* first */
    436         for (int ci = 0; ci < g_chan_count && n < max; ci++) {
    437             if (g_chans[ci].srv != si) continue;
    438             if (strcmp(g_chans[ci].name, "*status*") == 0)
    439                 idx[n++] = ci;
    440         }
    441         /* then regular channels */
    442         for (int ci = 0; ci < g_chan_count && n < max; ci++) {
    443             if (g_chans[ci].srv != si) continue;
    444             if (strcmp(g_chans[ci].name, "*status*") != 0)
    445                 idx[n++] = ci;
    446         }
    447     }
    448     return n;
    449 }
    450 
    451 static int chan_next(int cur, int dir) {
    452     if (g_chan_count <= 1) return cur;
    453     int idx[MAX_CHANS_TOTAL];
    454     int n = visual_order(idx, MAX_CHANS_TOTAL);
    455     if (n == 0) return cur;
    456     /* find cur in visual order */
    457     int pos = 0;
    458     for (int i = 0; i < n; i++)
    459         if (idx[i] == cur) { pos = i; break; }
    460     pos = (pos + dir + n) % n;
    461     return idx[pos];
    462 }
    463 
    464 /* ── ignore list ───────────────────────────────────────────────────────────── */
    465 
    466 static int is_ignored(const char *nick) {
    467     for (int i = 0; i < g_ignore_count; i++)
    468         if (strcasecmp(g_ignore[i], nick) == 0) return 1;
    469     return 0;
    470 }
    471 
    472 static void ignore_add(const char *nick) {
    473     if (is_ignored(nick) || g_ignore_count >= MAX_IGNORE) return;
    474     strncpy(g_ignore[g_ignore_count++], nick, MAX_NICK-1);
    475 }
    476 
    477 static void ignore_remove(const char *nick) {
    478     for (int i = 0; i < g_ignore_count; i++) {
    479         if (strcasecmp(g_ignore[i], nick) == 0) {
    480             for (int j = i; j < g_ignore_count-1; j++)
    481                 memcpy(g_ignore[j], g_ignore[j+1], MAX_NICK);
    482             g_ignore_count--;
    483             return;
    484         }
    485     }
    486 }
    487 
    488 /* ── event pipe ────────────────────────────────────────────────────────────── */
    489 
    490 static void ev_push(const Event *ev) {
    491     write(g_evpipe[1], ev, sizeof(Event));
    492 }
    493 
    494 static void ev_simple(EvType type, int srv, const char *nick,
    495                        const char *chan, const char *text) {
    496     Event ev; memset(&ev, 0, sizeof(ev));
    497     ev.type = type; ev.srv = srv;
    498     if (nick) strncpy(ev.nick, nick, MAX_NICK-1);
    499     if (chan) strncpy(ev.chan, chan,  MAX_CHAN-1);
    500     if (text) strncpy(ev.text, text, MAX_LINE-1);
    501     ev_push(&ev);
    502 }
    503 
    504 /* ── network send ──────────────────────────────────────────────────────────── */
    505 
    506 static int srv_send_raw(int si, const char *line) {
    507     char buf[MAX_LINE];
    508     int n = snprintf(buf, sizeof(buf), "%s\r\n", line);
    509     if (n <= 0 || n >= (int)sizeof(buf)) return -1;
    510     Server *s = &g_srv[si];
    511     pthread_mutex_lock(&s->send_lock);
    512     int r;
    513     if (s->use_tls && s->ssl)
    514         r = SSL_write(s->ssl, buf, n);
    515     else
    516         r = send(s->sock, buf, n, 0);
    517     pthread_mutex_unlock(&s->send_lock);
    518     return r > 0 ? 0 : -1;
    519 }
    520 
    521 static void srv_sendf(int si, const char *fmt, ...) {
    522     char buf[MAX_LINE];
    523     va_list ap; va_start(ap, fmt);
    524     vsnprintf(buf, sizeof(buf), fmt, ap);
    525     va_end(ap);
    526     srv_send_raw(si, buf);
    527 }
    528 
    529 /* ── IRC parser ────────────────────────────────────────────────────────────── */
    530 
    531 typedef struct {
    532     char prefix[MAX_LINE];
    533     char cmd[32];
    534     char params[16][MAX_LINE];
    535     int  nparams;
    536     char trail[MAX_LINE];
    537 } IrcMsg;
    538 
    539 static void nick_from_prefix(const char *prefix, char *nick, int n) {
    540     const char *bang = strchr(prefix, '!');
    541     if (bang) {
    542         int len = (int)(bang - prefix);
    543         if (len >= n) len = n-1;
    544         memcpy(nick, prefix, len); nick[len] = '\0';
    545     } else {
    546         strncpy(nick, prefix, n-1); nick[n-1] = '\0';
    547     }
    548 }
    549 
    550 static void parse_irc(const char *raw, IrcMsg *m) {
    551     memset(m, 0, sizeof(*m));
    552     const char *p = raw;
    553     if (*p == ':') {
    554         p++;
    555         const char *end = strchr(p, ' '); if (!end) return;
    556         int len = (int)(end-p); if (len>=(int)sizeof(m->prefix)) len=sizeof(m->prefix)-1;
    557         memcpy(m->prefix, p, len); p = end+1;
    558     }
    559     {
    560         const char *end = strchr(p, ' ');
    561         int len = end ? (int)(end-p) : (int)strlen(p);
    562         if (len>=(int)sizeof(m->cmd)) len=sizeof(m->cmd)-1;
    563         memcpy(m->cmd, p, len); str_upper(m->cmd, m->cmd, sizeof(m->cmd));
    564         if (!end) return; p = end+1;
    565     }
    566     while (*p) {
    567         if (*p == ':') { strncpy(m->trail, p+1, MAX_LINE-1); break; }
    568         const char *end = strchr(p, ' ');
    569         int len = end ? (int)(end-p) : (int)strlen(p);
    570         if (m->nparams < 16) {
    571             if (len>=MAX_LINE) len=MAX_LINE-1;
    572             memcpy(m->params[m->nparams], p, len); m->nparams++;
    573         }
    574         if (!end) break; p = end+1;
    575     }
    576 }
    577 
    578 /* ── base64 for SASL ───────────────────────────────────────────────────────── */
    579 
    580 static const char b64tab[] =
    581     "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    582 
    583 static void base64_encode(const unsigned char *in, int inlen, char *out) {
    584     int j = 0;
    585     for (int i = 0; i < inlen; i += 3) {
    586         unsigned int a = (unsigned char)in[i];
    587         unsigned int b = (i+1 < inlen) ? (unsigned char)in[i+1] : 0;
    588         unsigned int c = (i+2 < inlen) ? (unsigned char)in[i+2] : 0;
    589         unsigned int n = (a << 16) | (b << 8) | c;
    590         out[j++] = b64tab[(n >> 18) & 63];
    591         out[j++] = b64tab[(n >> 12) & 63];
    592         out[j++] = (i+1 < inlen) ? b64tab[(n >> 6) & 63] : '=';
    593         out[j++] = (i+2 < inlen) ? b64tab[n & 63]        : '=';
    594     }
    595     out[j] = '\0';
    596 }
    597 
    598 /* ── IRC line handler (net thread — uses si to tag every event) ────────────── */
    599 
    600 static void handle_irc_line(int si, const char *raw) {
    601     IrcMsg m; parse_irc(raw, &m);
    602     char nick[MAX_NICK]=""; nick_from_prefix(m.prefix, nick, sizeof(nick));
    603     Server *s = &g_srv[si];
    604 
    605     if (strcmp(m.cmd,"PING")==0) {
    606         srv_sendf(si, "PONG :%s", m.trail[0]?m.trail:m.params[0]);
    607         return;
    608     }
    609 
    610     if (strcmp(m.cmd,"PONG")==0) return;  /* ignore server's PONG replies */
    611 
    612     /* SASL / CAP negotiation */
    613     if (strcmp(m.cmd,"CAP")==0) {
    614         char sub[16]="";
    615         if (m.nparams>=2) str_upper(sub, m.params[1], sizeof(sub));
    616         else if (m.nparams>=1) str_upper(sub, m.params[0], sizeof(sub));
    617 
    618         /* log all CAP lines to status for visibility */
    619         char capdbg[MAX_LINE];
    620         snprintf(capdbg,sizeof(capdbg),"[CAP] %s",raw);
    621         ev_simple(EV_STATUS, si, NULL, NULL, capdbg);
    622 
    623         if (strcmp(sub,"LS")==0) {
    624             /* server advertising capabilities -- request sasl */
    625             srv_send_raw(si, "CAP REQ :sasl");
    626         } else if (strcmp(sub,"ACK")==0) {
    627             const char *caps = m.trail[0] ? m.trail : (m.nparams>0?m.params[m.nparams-1]:"");
    628             if (strstr(caps,"sasl")) {
    629                 if (!s->sasl_auth_sent) {
    630                     s->sasl_auth_sent = 1;
    631                     srv_send_raw(si, "AUTHENTICATE PLAIN");
    632                 }
    633             } else {
    634                 srv_send_raw(si, "CAP END");
    635             }
    636         } else if (strcmp(sub,"NAK")==0) {
    637             ev_simple(EV_ERROR, si, NULL, NULL, "Server rejected SASL CAP -- connecting without SASL.");
    638             srv_send_raw(si, "CAP END");
    639             srv_sendf(si, "NICK %s", s->nick);
    640             srv_sendf(si, "USER %s 0 * :sirc", s->nick);
    641         }
    642         return;
    643     }
    644     if (strcmp(m.cmd,"AUTHENTICATE")==0) {
    645         /* server sends AUTHENTICATE + (trail or param) to prompt us */
    646         const char *challenge = m.trail[0] ? m.trail :
    647                                 (m.nparams>0 ? m.params[0] : "");
    648         if (strcmp(challenge,"+")==0) {
    649             /* build PLAIN payload: authzid\0authcid\0passwd
    650                must use memcpy — sasl_user/pass may not contain \0
    651                but we need to embed \0 separators manually */
    652             char payload[512];
    653             int ulen = strlen(s->sasl_user);
    654             int plen_pass = strlen(s->sasl_pass);
    655             /* layout: [sasl_user]\0[sasl_user]\0[sasl_pass] */
    656             int total = ulen + 1 + ulen + 1 + plen_pass;
    657             if (total < (int)sizeof(payload)) {
    658                 int pos = 0;
    659                 memcpy(payload + pos, s->sasl_user, ulen); pos += ulen;
    660                 payload[pos++] = '\0';
    661                 memcpy(payload + pos, s->sasl_user, ulen); pos += ulen;
    662                 payload[pos++] = '\0';
    663                 memcpy(payload + pos, s->sasl_pass, plen_pass); pos += plen_pass;
    664                 char enc[700];
    665                 base64_encode((unsigned char*)payload, total, enc);
    666                 srv_sendf(si, "AUTHENTICATE %s", enc);
    667             }
    668         }
    669         return;
    670     }
    671     if (strcmp(m.cmd,"903")==0) {
    672         ev_simple(EV_STATUS, si, NULL, NULL, "SASL authentication successful.");
    673         srv_send_raw(si, "CAP END");
    674         srv_sendf(si, "NICK %s", s->nick);
    675         srv_sendf(si, "USER %s 0 * :sirc", s->nick);
    676         return;
    677     }
    678     if (strcmp(m.cmd,"902")==0||strcmp(m.cmd,"904")==0||
    679         strcmp(m.cmd,"905")==0||strcmp(m.cmd,"906")==0) {
    680         char buf[MAX_LINE];
    681         snprintf(buf,sizeof(buf),"SASL failed (%s): %s",m.cmd,m.trail);
    682         ev_simple(EV_ERROR, si, NULL, NULL, buf);
    683         srv_send_raw(si, "CAP END");
    684         srv_sendf(si, "NICK %s", s->nick);
    685         srv_sendf(si, "USER %s 0 * :sirc", s->nick);
    686         return;
    687     }
    688 
    689     /* 001 */
    690     if (strcmp(m.cmd,"001")==0) {
    691         s->connected=1;
    692         ev_simple(EV_CONNECTED, si, NULL, NULL, m.trail);
    693         ev_simple(EV_SERVER_TEXT, si, NULL, NULL, m.trail);
    694         return;
    695     }
    696 
    697     /* PRIVMSG */
    698     if (strcmp(m.cmd,"PRIVMSG")==0) {
    699         Event ev; memset(&ev,0,sizeof(ev));
    700         ev.type=EV_PRIVMSG; ev.srv=si;
    701         strncpy(ev.nick, nick, MAX_NICK-1);
    702         strncpy(ev.chan, m.nparams>0?m.params[0]:"", MAX_CHAN-1);
    703         strncpy(ev.text, m.trail, MAX_LINE-1);
    704         ev_push(&ev); return;
    705     }
    706 
    707     /* JOIN */
    708     if (strcmp(m.cmd,"JOIN")==0) {
    709         const char *ch=m.trail[0]?m.trail:(m.nparams>0?m.params[0]:"");
    710         ev_simple(EV_JOIN, si, nick, ch, NULL); return;
    711     }
    712 
    713     /* PART */
    714     if (strcmp(m.cmd,"PART")==0) {
    715         ev_simple(EV_PART, si, nick, m.nparams>0?m.params[0]:"", m.trail);
    716         return;
    717     }
    718 
    719     /* QUIT */
    720     if (strcmp(m.cmd,"QUIT")==0) {
    721         ev_simple(EV_QUIT_MSG, si, nick, NULL, m.trail); return;
    722     }
    723 
    724     /* NICK */
    725     if (strcmp(m.cmd,"NICK")==0) {
    726         Event ev; memset(&ev,0,sizeof(ev));
    727         ev.type=EV_NICK_CHANGE; ev.srv=si;
    728         strncpy(ev.nick, nick, MAX_NICK-1);
    729         const char *nn=m.trail[0]?m.trail:(m.nparams>0?m.params[0]:"");
    730         strncpy(ev.extra, nn, MAX_NICK-1);
    731         ev_push(&ev); return;
    732     }
    733 
    734     /* 353 NAMES */
    735     if (strcmp(m.cmd,"353")==0) {
    736         const char *ch=m.nparams>=3?m.params[2]:"";
    737         char tmp[MAX_LINE]; strncpy(tmp,m.trail,MAX_LINE-1);
    738         char *tok=strtok(tmp," ");
    739         while (tok) {
    740             Event ev; memset(&ev,0,sizeof(ev));
    741             ev.type=EV_NAMES; ev.srv=si;
    742             strncpy(ev.chan, ch, MAX_CHAN-1);
    743             /* ev.extra[0] = mode prefix char (or 0 if none) */
    744             const char *n=tok;
    745             if (*n=='@'||*n=='+'||*n=='~'||*n=='&'||*n=='%') {
    746                 ev.extra[0]=*n; n++;
    747             }
    748             strncpy(ev.nick, n, MAX_NICK-1);
    749             ev_push(&ev);
    750             tok=strtok(NULL," ");
    751         }
    752         return;
    753     }
    754 
    755     /* KICK */
    756     if (strcmp(m.cmd,"KICK")==0) {
    757         Event ev; memset(&ev,0,sizeof(ev));
    758         ev.type=EV_KICK; ev.srv=si;
    759         strncpy(ev.nick,  nick, MAX_NICK-1);
    760         strncpy(ev.chan,  m.nparams>0?m.params[0]:"", MAX_CHAN-1);
    761         strncpy(ev.extra, m.nparams>1?m.params[1]:"", MAX_NICK-1);
    762         strncpy(ev.text,  m.trail, MAX_LINE-1);
    763         ev_push(&ev); return;
    764     }
    765 
    766     /* 433 nick in use */
    767     if (strcmp(m.cmd,"433")==0) {
    768         ev_simple(EV_ERROR, si, NULL, NULL, "Nickname already in use.");
    769         return;
    770     }
    771 
    772     /* server text */
    773     if (strcmp(m.cmd,"002")==0||strcmp(m.cmd,"003")==0||strcmp(m.cmd,"004")==0||
    774         strcmp(m.cmd,"375")==0||strcmp(m.cmd,"372")==0||strcmp(m.cmd,"376")==0) {
    775         ev_simple(EV_SERVER_TEXT, si, NULL, NULL,
    776                   m.trail[0]?m.trail:m.params[m.nparams>1?1:0]);
    777         return;
    778     }
    779 
    780     /* MODE */
    781     if (strcmp(m.cmd,"MODE")==0) {
    782         char buf[MAX_LINE];
    783         snprintf(buf,sizeof(buf),"MODE %s %s",m.params[0],m.trail);
    784         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    785     }
    786 
    787     /* TOPIC */
    788     if (strcmp(m.cmd,"331")==0) {
    789         const char *ch=m.nparams>1?m.params[1]:m.params[0];
    790         char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] No topic set",ch);
    791         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    792     }
    793     if (strcmp(m.cmd,"332")==0) {
    794         const char *ch=m.nparams>1?m.params[1]:m.params[0];
    795         char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] Topic: %s",ch,m.trail);
    796         ev_simple(EV_STATUS, si, NULL, NULL, buf);
    797         ev_simple(EV_TOPIC,  si, NULL, ch, m.trail);
    798         return;
    799     }
    800     if (strcmp(m.cmd,"333")==0) {
    801         const char *ch=m.nparams>1?m.params[1]:"";
    802         const char *setter=m.nparams>2?m.params[2]:"";
    803         char sn[MAX_NICK]; strncpy(sn,setter,MAX_NICK-1);
    804         char *bang=strchr(sn,'!'); if(bang)*bang='\0';
    805         char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] Topic set by %s",ch,sn);
    806         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    807     }
    808     if (strcmp(m.cmd,"TOPIC")==0) {
    809         const char *ch=m.nparams>0?m.params[0]:"";
    810         char buf[MAX_LINE];
    811         snprintf(buf,sizeof(buf),"[%s] %s changed topic to: %s",ch,nick,m.trail);
    812         ev_simple(EV_STATUS,      si, NULL, NULL, buf);
    813         ev_simple(EV_SERVER_TEXT, si, NULL, ch,   buf);
    814         ev_simple(EV_TOPIC,       si, NULL, ch,   m.trail);
    815         return;
    816     }
    817 
    818     /* WHOIS */
    819     if (strcmp(m.cmd,"311")==0) {
    820         char buf[MAX_LINE];
    821         snprintf(buf,sizeof(buf),"[whois] %s (%s@%s): %s",
    822                  m.nparams>1?m.params[1]:"",m.nparams>2?m.params[2]:"",
    823                  m.nparams>3?m.params[3]:"",m.trail);
    824         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    825     }
    826     if (strcmp(m.cmd,"312")==0) {
    827         char buf[MAX_LINE];
    828         snprintf(buf,sizeof(buf),"[whois] server: %s (%s)",
    829                  m.nparams>2?m.params[2]:"",m.trail);
    830         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    831     }
    832     if (strcmp(m.cmd,"313")==0) {
    833         char buf[MAX_LINE];
    834         snprintf(buf,sizeof(buf),"[whois] %s is an IRC operator",
    835                  m.nparams>1?m.params[1]:"");
    836         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    837     }
    838     if (strcmp(m.cmd,"317")==0) {
    839         int idle=m.nparams>2?atoi(m.params[2]):0;
    840         char buf[MAX_LINE];
    841         snprintf(buf,sizeof(buf),"[whois] idle: %dm%ds",idle/60,idle%60);
    842         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    843     }
    844     if (strcmp(m.cmd,"318")==0) {
    845         ev_simple(EV_STATUS, si, NULL, NULL, "[whois] end"); return;
    846     }
    847     if (strcmp(m.cmd,"319")==0) {
    848         char buf[MAX_LINE];
    849         snprintf(buf,sizeof(buf),"[whois] channels: %s",m.trail);
    850         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    851     }
    852     if (strcmp(m.cmd,"307")==0||strcmp(m.cmd,"330")==0) {
    853         char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] %s",m.trail);
    854         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    855     }
    856 
    857     /* WHO */
    858     if (strcmp(m.cmd,"352")==0) {
    859         char buf[MAX_LINE];
    860         snprintf(buf,sizeof(buf),"[who] %-16s %s!%s@%s",
    861                  m.nparams>1?m.params[1]:"*",
    862                  m.nparams>6?m.params[6]:"",
    863                  m.nparams>2?m.params[2]:"",
    864                  m.nparams>3?m.params[3]:"");
    865         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    866     }
    867     if (strcmp(m.cmd,"315")==0) {
    868         ev_simple(EV_STATUS, si, NULL, NULL, "[who] end"); return;
    869     }
    870 
    871     /* LIST */
    872     if (strcmp(m.cmd,"321")==0) {
    873         ev_simple(EV_STATUS, si, NULL, NULL, "[list] Channel          Users  Topic");
    874         return;
    875     }
    876     if (strcmp(m.cmd,"322")==0) {
    877         char buf[MAX_LINE];
    878         snprintf(buf,sizeof(buf),"[list] %-20s %-6s %s",
    879                  m.nparams>1?m.params[1]:"",
    880                  m.nparams>2?m.params[2]:"",m.trail);
    881         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    882     }
    883     if (strcmp(m.cmd,"323")==0) {
    884         ev_simple(EV_STATUS, si, NULL, NULL, "[list] end"); return;
    885     }
    886 
    887     if (strcmp(m.cmd,"366")==0) {
    888         /* end of NAMES — reset pending flag so next /names clears again */
    889         const char *ch = m.nparams>=2 ? m.params[1] : (m.nparams>=1 ? m.params[0] : "");
    890         ev_simple(EV_NAMES_END, si, NULL, ch, NULL);
    891         return;
    892     }
    893 
    894     /* AWAY */
    895     if (strcmp(m.cmd,"301")==0) {
    896         char buf[MAX_LINE];
    897         snprintf(buf,sizeof(buf),"[away] %s is away: %s",
    898                  m.nparams>1?m.params[1]:"",m.trail);
    899         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    900     }
    901     if (strcmp(m.cmd,"305")==0) {
    902         ev_simple(EV_STATUS, si, NULL, NULL, "You are no longer marked as away.");
    903         return;
    904     }
    905     if (strcmp(m.cmd,"306")==0) {
    906         ev_simple(EV_STATUS, si, NULL, NULL, "You have been marked as away.");
    907         return;
    908     }
    909 
    910     /* INVITE */
    911     if (strcmp(m.cmd,"INVITE")==0) {
    912         char buf[MAX_LINE];
    913         snprintf(buf,sizeof(buf),"** %s invites you to %s",nick,
    914                  m.trail[0]?m.trail:m.params[m.nparams>1?1:0]);
    915         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    916     }
    917     if (strcmp(m.cmd,"341")==0) {
    918         char buf[MAX_LINE];
    919         snprintf(buf,sizeof(buf),"Invited %s to %s",
    920                  m.nparams>1?m.params[1]:"",m.nparams>2?m.params[2]:m.trail);
    921         ev_simple(EV_STATUS, si, NULL, NULL, buf); return;
    922     }
    923 
    924     /* NOTICE */
    925     if (strcmp(m.cmd,"NOTICE")==0) {
    926         const char *target=m.nparams>0?m.params[0]:"";
    927         char buf[MAX_LINE];
    928         snprintf(buf,sizeof(buf),"--%s-- %s",nick[0]?nick:"server",m.trail);
    929         if (target[0]=='#') {
    930             Event ev2; memset(&ev2,0,sizeof(ev2));
    931             ev2.type=EV_SERVER_TEXT; ev2.srv=si;
    932             strncpy(ev2.chan, target, MAX_CHAN-1);
    933             strncpy(ev2.text, buf, MAX_LINE-1);
    934             ev_push(&ev2);
    935         } else {
    936             ev_simple(EV_STATUS, si, NULL, NULL, buf);
    937         }
    938         return;
    939     }
    940 
    941     ev_simple(EV_RAW, si, NULL, NULL, raw);
    942 }
    943 
    944 /* ── network thread ────────────────────────────────────────────────────────── */
    945 
    946 static void srv_close(int si) {
    947     Server *s=&g_srv[si];
    948     if (s->use_tls && s->ssl) { SSL_shutdown(s->ssl); SSL_free(s->ssl); s->ssl=NULL; }
    949     if (s->sock>=0) { close(s->sock); s->sock=-1; }
    950     s->connected=0;
    951 }
    952 
    953 static int srv_connect(int si) {
    954     Server *s=&g_srv[si];
    955     char portstr[8]; snprintf(portstr,sizeof(portstr),"%d",s->port);
    956     struct addrinfo hints,*res,*r;
    957     memset(&hints,0,sizeof(hints));
    958     hints.ai_family=AF_UNSPEC; hints.ai_socktype=SOCK_STREAM;
    959     if (getaddrinfo(s->host,portstr,&hints,&res)!=0) return -1;
    960     s->sock=-1;
    961     for (r=res; r; r=r->ai_next) {
    962         int fd=socket(r->ai_family,r->ai_socktype,r->ai_protocol);
    963         if (fd<0) continue;
    964         int flags=fcntl(fd,F_GETFL,0);
    965         fcntl(fd,F_SETFL,flags|O_NONBLOCK);
    966         int rc=connect(fd,r->ai_addr,r->ai_addrlen);
    967         if (rc==0||errno==EINPROGRESS) {
    968             fd_set wfds; FD_ZERO(&wfds); FD_SET(fd,&wfds);
    969             struct timeval tv={15,0};
    970             if (select(fd+1,NULL,&wfds,NULL,&tv)>0) {
    971                 int err=0; socklen_t el=sizeof(err);
    972                 getsockopt(fd,SOL_SOCKET,SO_ERROR,&err,&el);
    973                 if (err==0) { s->sock=fd; break; }
    974             }
    975         }
    976         close(fd);
    977     }
    978     freeaddrinfo(res);
    979     if (s->sock<0) return -1;
    980     int flags=fcntl(s->sock,F_GETFL,0);
    981     fcntl(s->sock,F_SETFL,flags&~O_NONBLOCK);
    982 
    983     if (s->use_tls) {
    984         if (!g_ssl_ctx) {
    985             SSL_library_init(); SSL_load_error_strings();
    986             g_ssl_ctx=SSL_CTX_new(TLS_client_method());
    987             if (!g_ssl_ctx) return -1;
    988             SSL_CTX_set_verify(g_ssl_ctx,SSL_VERIFY_PEER,NULL);
    989             SSL_CTX_set_default_verify_paths(g_ssl_ctx);
    990         }
    991         s->ssl=SSL_new(g_ssl_ctx);
    992         SSL_set_fd(s->ssl,s->sock);
    993         SSL_set_tlsext_host_name(s->ssl,s->host);
    994         if (SSL_connect(s->ssl)<=0) {
    995             SSL_free(s->ssl); s->ssl=NULL; return -1;
    996         }
    997     }
    998     return 0;
    999 }
   1000 
   1001 typedef struct { int si; } NetArg;
   1002 
   1003 static void *net_thread(void *arg) {
   1004     NetArg *na=(NetArg*)arg; int si=na->si; free(na);
   1005     Server *s=&g_srv[si];
   1006 
   1007     char buf[MAX_LINE];
   1008     snprintf(buf,sizeof(buf),"Connecting to %s:%d%s...",
   1009              s->host,s->port,s->use_tls?" (TLS)":"");
   1010     ev_simple(EV_STATUS, si, NULL, NULL, buf);
   1011 
   1012     if (srv_connect(si)<0) {
   1013         ev_simple(EV_ERROR, si, NULL, NULL, "Connection failed.");
   1014         ev_simple(EV_RECONNECT, si, NULL, NULL, NULL);
   1015         return NULL;
   1016     }
   1017 
   1018     int sasl_pending=(s->sasl_user[0]&&s->sasl_pass[0])?1:0;
   1019     s->sasl_auth_sent = 0;
   1020     if (sasl_pending) {
   1021         /* CAP LS 302: proper negotiation, handler sends CAP REQ :sasl on LS reply */
   1022         srv_send_raw(si,"CAP LS 302");
   1023     } else {
   1024         srv_sendf(si,"NICK %s",s->nick);
   1025         srv_sendf(si,"USER %s 0 * :sirc",s->nick);
   1026     }
   1027 
   1028     char readbuf[8192], linebuf[MAX_LINE*4];
   1029     int linelen=0;
   1030     time_t last_ping=time(NULL);
   1031 
   1032     while (!s->net_stop) {
   1033         if (time(NULL)-last_ping>PING_INTERVAL) {
   1034             srv_sendf(si,"PING :%s",s->host);
   1035             last_ping=time(NULL);
   1036         }
   1037         fd_set rfds; FD_ZERO(&rfds); FD_SET(s->sock,&rfds);
   1038         struct timeval tv={5,0};
   1039         int sel=select(s->sock+1,&rfds,NULL,NULL,&tv);
   1040         if (sel==0) continue;
   1041         if (sel<0) { ev_simple(EV_ERROR,si,NULL,NULL,"Select error."); break; }
   1042 
   1043         int n;
   1044         if (s->use_tls && s->ssl)
   1045             n=SSL_read(s->ssl,readbuf,sizeof(readbuf)-1);
   1046         else
   1047             n=recv(s->sock,readbuf,sizeof(readbuf)-1,0);
   1048         if (n<=0) { ev_simple(EV_ERROR,si,NULL,NULL,"Server closed connection."); break; }
   1049         readbuf[n]='\0';
   1050 
   1051         for (int i=0; i<n; i++) {
   1052             char c=readbuf[i];
   1053             if (c=='\n') {
   1054                 if (linelen>0 && linebuf[linelen-1]=='\r') linelen--;
   1055                 linebuf[linelen]='\0';
   1056                 if (linelen>0) handle_irc_line(si,linebuf);
   1057                 linelen=0;
   1058             } else if (linelen<(int)sizeof(linebuf)-1) {
   1059                 linebuf[linelen++]=c;
   1060             }
   1061         }
   1062         last_ping=time(NULL);
   1063     }
   1064 
   1065     srv_close(si);
   1066     ev_simple(EV_RECONNECT, si, NULL, NULL, NULL);
   1067     return NULL;
   1068 }
   1069 
   1070 /* ── reconnect ─────────────────────────────────────────────────────────────── */
   1071 
   1072 static void start_net_thread(int si);
   1073 
   1074 typedef struct { int si; } ReconArg;
   1075 static void *reconnect_thread(void *arg) {
   1076     ReconArg *ra=(ReconArg*)arg; int si=ra->si; free(ra);
   1077     sleep(RECONNECT_DELAY);
   1078     g_srv[si].reconnect_pending=0;
   1079     start_net_thread(si);
   1080     return NULL;
   1081 }
   1082 
   1083 static void start_net_thread(int si) {
   1084     g_srv[si].net_stop=0;
   1085     NetArg *na=malloc(sizeof(NetArg)); na->si=si;
   1086     pthread_create(&g_srv[si].net_tid, NULL, net_thread, na);
   1087 }
   1088 
   1089 static void schedule_reconnect(int si) {
   1090     if (g_srv[si].reconnect_pending) return;
   1091     g_srv[si].reconnect_pending=1;
   1092     srv_status_fmt(si,"Reconnecting in %ds...",RECONNECT_DELAY);
   1093     ReconArg *ra=malloc(sizeof(ReconArg)); ra->si=si;
   1094     pthread_create(&g_srv[si].reconnect_tid, NULL, reconnect_thread, ra);
   1095     pthread_detach(g_srv[si].reconnect_tid);
   1096 }
   1097 
   1098 /* ── config file ───────────────────────────────────────────────────────────── */
   1099 
   1100 static int cfg_bool(const char *v) {
   1101     return strcasecmp(v,"true")==0||strcasecmp(v,"yes")==0||
   1102            strcasecmp(v,"1")==0||strcasecmp(v,"on")==0;
   1103 }
   1104 
   1105 /* add a server entry with defaults; returns its index */
   1106 static int srv_alloc(void) {
   1107     if (g_srv_count>=MAX_SERVERS) return -1;
   1108     int si=g_srv_count++;
   1109     Server *s=&g_srv[si];
   1110     memset(s,0,sizeof(Server));
   1111     strncpy(s->host,"irc.libera.chat",MAX_HOST-1);
   1112     s->port=6697;
   1113     strncpy(s->nick,"circ_user",MAX_NICK-1);
   1114     s->use_tls=1;
   1115     s->sock=-1;
   1116     pthread_mutex_init(&s->send_lock,NULL);
   1117     return si;
   1118 }
   1119 
   1120 static void srv_add_autojoin(int si, const char *chanlist) {
   1121     char tmp[MAX_LINE]; strncpy(tmp, chanlist, MAX_LINE-1);
   1122     char *tok = strtok(tmp, ",");
   1123     while (tok) {
   1124         /* trim leading and trailing spaces */
   1125         while (*tok == ' ') tok++;
   1126         char *end = tok + strlen(tok) - 1;
   1127         while (end > tok && *end == ' ') *end-- = '\0';
   1128         if (*tok && g_srv[si].autojoin_count < MAX_AUTOJOIN) {
   1129             char ch[MAX_CHAN]; strncpy(ch, tok, MAX_CHAN-1);
   1130             if (ch[0] != '#') { memmove(ch+1, ch, strlen(ch)+1); ch[0]='#'; }
   1131             /* avoid duplicates */
   1132             int dup = 0;
   1133             for (int i = 0; i < g_srv[si].autojoin_count; i++)
   1134                 if (strcasecmp(g_srv[si].autojoin[i], ch) == 0) { dup=1; break; }
   1135             if (!dup)
   1136                 strncpy(g_srv[si].autojoin[g_srv[si].autojoin_count++], ch, MAX_CHAN-1);
   1137         }
   1138         tok = strtok(NULL, ",");
   1139     }
   1140 }
   1141 
   1142 static void load_config(const char *path) {
   1143     char candidates[2][MAX_HOST];
   1144     const char *home=getenv("HOME");
   1145     if (home) {
   1146         snprintf(candidates[0],MAX_HOST,"%s/.sirc",home);
   1147         snprintf(candidates[1],MAX_HOST,"%s/.config/sirc/config",home);
   1148     }
   1149     FILE *f=NULL;
   1150     if (path) f=fopen(path,"r");
   1151     else for (int i=0;i<2&&!f;i++) f=fopen(candidates[i],"r");
   1152     if (!f) return;
   1153 
   1154     /* defaults that apply before any [server] block */
   1155     char def_nick[MAX_NICK]="circ_user";
   1156 
   1157     /* current server being parsed; -1 = not inside a [server] block */
   1158     int cur_si=-1;
   1159 
   1160     char line[512];
   1161     while (fgets(line,sizeof(line),f)) {
   1162         line[strcspn(line,"\r\n")]='\0';
   1163         char *p=line;
   1164         while (*p==' '||*p=='\t') p++;
   1165         if (!*p||*p=='#') continue;
   1166 
   1167         /* [server] section header */
   1168         if (strcmp(p,"[server]")==0) {
   1169             cur_si=srv_alloc();
   1170             if (cur_si>=0) strncpy(g_srv[cur_si].nick,def_nick,MAX_NICK-1);
   1171             continue;
   1172         }
   1173 
   1174         char *eq=strchr(p,'='); if (!eq) continue;
   1175         *eq='\0';
   1176         char *key=p, *val=eq+1;
   1177         char *ke=key+strlen(key)-1;
   1178         while (ke>key&&(*ke==' '||*ke=='\t')) *ke--='\0';
   1179         while (*val==' '||*val=='\t') val++;
   1180         char *ve=val+strlen(val)-1;
   1181         while (ve>val&&(*ve==' '||*ve=='\t')) *ve--='\0';
   1182 
   1183         if (cur_si<0) {
   1184             /* global defaults */
   1185             if (strcmp(key,"nick")==0) strncpy(def_nick,val,MAX_NICK-1);
   1186             else if (strcmp(key,"ignore")==0) {
   1187                 char tmp[512]; strncpy(tmp,val,511);
   1188                 char *tok=strtok(tmp,",");
   1189                 while (tok) { while(*tok==' ')tok++; if(*tok)ignore_add(tok); tok=strtok(NULL,","); }
   1190             }
   1191         } else {
   1192             Server *s=&g_srv[cur_si];
   1193             if (strcmp(key,"host")==0)       strncpy(s->host,val,MAX_HOST-1);
   1194             else if (strcmp(key,"port")==0)  s->port=atoi(val);
   1195             else if (strcmp(key,"nick")==0)  strncpy(s->nick,val,MAX_NICK-1);
   1196             else if (strcmp(key,"tls")==0)   s->use_tls=cfg_bool(val);
   1197             else if (strcmp(key,"sasl_user")==0) strncpy(s->sasl_user,val,MAX_NICK-1);
   1198             else if (strcmp(key,"sasl_pass")==0) strncpy(s->sasl_pass,val,255);
   1199             else if (strcmp(key,"channel")==0)   srv_add_autojoin(cur_si,val);
   1200         }
   1201     }
   1202     fclose(f);
   1203 }
   1204 
   1205 /* ── URL detection ─────────────────────────────────────────────────────────── */
   1206 
   1207 static int find_url(const char *str, int start, int *len) {
   1208     for (int i=start; str[i]; i++) {
   1209         if (strncmp(str+i,"http://",7)==0||strncmp(str+i,"https://",8)==0||
   1210             strncmp(str+i,"www.",4)==0) {
   1211             int j=i;
   1212             while (str[j]&&str[j]!=' '&&str[j]!='\t'&&
   1213                    str[j]!='"'&&str[j]!='\''&&str[j]!='<') j++;
   1214             *len=j-i; return i;
   1215         }
   1216     }
   1217     return -1;
   1218 }
   1219 
   1220 /* ── draw routines ─────────────────────────────────────────────────────────── */
   1221 
   1222 static void draw_status_bar(void) {
   1223     int H,W; getmaxyx(stdscr,H,W); (void)H;
   1224     werase(win_status);
   1225     wbkgd(win_status,COLOR_PAIR(C_HEADER));
   1226     Channel *ch=&g_chans[g_active];
   1227     /* topic starts after the channel panel, truncated on the right */
   1228     int avail = W - CHAN_W - 1;
   1229     if (avail < 1) avail = 1;
   1230     char row[512];
   1231     if (ch->topic[0])
   1232         snprintf(row, sizeof(row), " %.*s", avail-1, ch->topic);
   1233     else
   1234         row[0]='\0';
   1235     int l=strlen(row);
   1236     while (l < avail) row[l++]=' ';
   1237     row[avail]='\0';
   1238     mvwaddnstr(win_status, 0, CHAN_W, row, avail);
   1239     wnoutrefresh(win_status);
   1240 }
   1241 
   1242 static void draw_channels(void) {
   1243     int H,W; getmaxyx(win_chan,H,W); (void)W;
   1244     werase(win_chan);
   1245     wattron(win_chan,COLOR_PAIR(C_BORDER)|A_BOLD);
   1246     mvwaddnstr(win_chan,0,0," CHANNELS",CHAN_W-1);
   1247     wattroff(win_chan,COLOR_PAIR(C_BORDER)|A_BOLD);
   1248 
   1249     int row=2;
   1250     for (int si=0; si<g_srv_count && row<H-1; si++) {
   1251         /* server header: full hostname, ~ prefix if disconnected */
   1252         char hdr[CHAN_W];
   1253         const char *conn_mark = g_srv[si].connected ? "" : "~";
   1254         snprintf(hdr,sizeof(hdr),"%s%.*s",conn_mark,(int)(CHAN_W-2),g_srv[si].host);
   1255         int hl=strlen(hdr);
   1256         while (hl<CHAN_W-1) hdr[hl++]=' '; hdr[CHAN_W-1]='\0';
   1257         wattron(win_chan,COLOR_PAIR(C_SERVER_HDR)|A_BOLD);
   1258         mvwaddnstr(win_chan,row,0,hdr,CHAN_W-1);
   1259         wattroff(win_chan,COLOR_PAIR(C_SERVER_HDR)|A_BOLD);
   1260         row++;
   1261 
   1262         /* show *status* first, then regular channels */
   1263         for (int pass=0; pass<2 && row<H-1; pass++) {
   1264             for (int ci=0; ci<g_chan_count && row<H-1; ci++) {
   1265                 if (g_chans[ci].srv!=si) continue;
   1266                 int is_status=(strcmp(g_chans[ci].name,"*status*")==0);
   1267                 if (pass==0 && !is_status) continue;  /* pass 0: status only */
   1268                 if (pass==1 &&  is_status) continue;  /* pass 1: channels only */
   1269                 Channel *ch=&g_chans[ci];
   1270                 int attr; const char *pfx;
   1271                 if (ci==g_active) {
   1272                     attr=COLOR_PAIR(C_CHAN_SEL)|A_BOLD; pfx="> ";
   1273                 } else if (is_status) {
   1274                     attr=COLOR_PAIR(C_STATUS)|A_DIM; pfx="  ";
   1275                 } else if (ch->mention) {
   1276                     attr=COLOR_PAIR(C_MENTION_CHAN)|A_BOLD; pfx="! ";
   1277                 } else if (ch->unread) {
   1278                     attr=COLOR_PAIR(C_UNREAD); pfx="+ ";
   1279                 } else {
   1280                     attr=COLOR_PAIR(C_CHAN); pfx="  ";
   1281                 }
   1282                 char label[CHAN_W+3];
   1283                 snprintf(label,sizeof(label)," %s%.*s",pfx,CHAN_W-4,ch->name);
   1284                 int ll=strlen(label);
   1285                 while (ll<CHAN_W-1) label[ll++]=' '; label[CHAN_W-1]='\0';
   1286                 wattron(win_chan,attr);
   1287                 mvwaddnstr(win_chan,row,0,label,CHAN_W-1);
   1288                 wattroff(win_chan,attr);
   1289                 row++;
   1290             }
   1291         }
   1292     }
   1293 
   1294     /* bottom: active channel name */
   1295     char hint[CHAN_W];
   1296     snprintf(hint,sizeof(hint),"%.*s",CHAN_W-1,g_chans[g_active].name);
   1297     int hl=strlen(hint);
   1298     while (hl<CHAN_W-1) hint[hl++]=' '; hint[CHAN_W-1]='\0';
   1299     wattron(win_chan,COLOR_PAIR(C_STATUS)|A_DIM);
   1300     mvwaddnstr(win_chan,H-1,0,hint,CHAN_W-1);
   1301     wattroff(win_chan,COLOR_PAIR(C_STATUS)|A_DIM);
   1302 
   1303     wnoutrefresh(win_chan);
   1304 }
   1305 
   1306 static void draw_chat(void) {
   1307     int chat_h,chat_w; getmaxyx(win_chat,chat_h,chat_w);
   1308     werase(win_chat);
   1309     Channel *ch=&g_chans[g_active];
   1310     if (ch->line_count==0) { wnoutrefresh(win_chat); return; }
   1311 
   1312     typedef struct { int flag; char text[MAX_LINE]; int has_url; } FlatLine;
   1313     static FlatLine flat[MAX_HISTORY*4];
   1314     int flat_count=0;
   1315 
   1316     for (int li=0; li<ch->line_count && flat_count<(int)(sizeof(flat)/sizeof(flat[0])); li++) {
   1317         Line *ln=chan_line(ch,li); if (!ln) continue;
   1318         char full[MAX_LINE];
   1319         snprintf(full,sizeof(full),"%s %s",ln->ts,ln->text);
   1320         int plen=strlen(ln->ts)+1;
   1321         int avail=chat_w-plen; if(avail<4)avail=4;
   1322         const char *p=full; int is_first=1;
   1323         while (*p && flat_count<(int)(sizeof(flat)/sizeof(flat[0]))) {
   1324             int w=is_first?chat_w:avail;
   1325             FlatLine *fl=&flat[flat_count++];
   1326             fl->flag=ln->flag;
   1327             fl->has_url=(find_url(ln->text,0,&(int){0})>=0);
   1328             if ((int)strlen(p)<=w) {
   1329                 if (!is_first) { memset(fl->text,' ',plen); strncpy(fl->text+plen,p,MAX_LINE-plen-1); }
   1330                 else strncpy(fl->text,p,MAX_LINE-1);
   1331                 break;
   1332             }
   1333             int cut=w;
   1334             while (cut>0&&p[cut]!=' ') cut--;
   1335             if (cut==0) cut=w;
   1336             if (!is_first) { memset(fl->text,' ',plen); memcpy(fl->text+plen,p,cut); fl->text[plen+cut]='\0'; }
   1337             else { memcpy(fl->text,p,cut); fl->text[cut]='\0'; }
   1338             is_first=0; p+=cut; while(*p==' ')p++;
   1339         }
   1340     }
   1341 
   1342     int max_scroll=flat_count>chat_h?flat_count-chat_h:0;
   1343     if (ch->scroll>max_scroll) ch->scroll=max_scroll;
   1344     if (ch->scroll<0) ch->scroll=0;
   1345     int vis_start=flat_count-chat_h-ch->scroll;
   1346     if (vis_start<0) vis_start=0;
   1347     int vis_end=vis_start+chat_h;
   1348     if (vis_end>flat_count) vis_end=flat_count;
   1349 
   1350     for (int i=vis_start; i<vis_end; i++) {
   1351         int row=i-vis_start;
   1352         FlatLine *fl=&flat[i];
   1353         if (fl->flag==F_MSG) {
   1354             wattron(win_chat,COLOR_PAIR(C_STATUS));
   1355             mvwaddnstr(win_chat,row,0,fl->text,chat_w-1);
   1356             wattroff(win_chat,COLOR_PAIR(C_STATUS));
   1357             const char *lt=strchr(fl->text,'<');
   1358             const char *gt=lt?strchr(lt,'>'):NULL;
   1359             if (lt&&gt) {
   1360                 int ns=(int)(lt-fl->text), nl=(int)(gt-lt+1);
   1361                 char nn[MAX_NICK]; int nlen=nl-2; if(nlen>=MAX_NICK)nlen=MAX_NICK-1;
   1362                 memcpy(nn,lt+1,nlen); nn[nlen]='\0';
   1363                 mvwchgat(win_chat,row,ns,nl,A_BOLD,nick_colour(nn),NULL);
   1364             }
   1365         } else {
   1366             int attr;
   1367             switch(fl->flag) {
   1368                 case F_ME:      attr=COLOR_PAIR(C_ME)|A_BOLD;    break;
   1369                 case F_MENTION: attr=COLOR_PAIR(C_MENTION)|A_BOLD; break;
   1370                 case F_JOIN:    attr=COLOR_PAIR(C_JOIN);          break;
   1371                 case F_ACTION:  attr=COLOR_PAIR(C_ACTION)|A_ITALIC; break;
   1372                 default:        attr=COLOR_PAIR(C_STATUS);        break;
   1373             }
   1374             wattron(win_chat,attr);
   1375             mvwaddnstr(win_chat,row,0,fl->text,chat_w-1);
   1376             wattroff(win_chat,attr);
   1377         }
   1378         if (fl->has_url) {
   1379             int ustart=0,ulen,us;
   1380             while ((us=find_url(fl->text,ustart,&ulen))>=0) {
   1381                 if (us>=chat_w-1) break;
   1382                 int ue=us+ulen; if(ue>chat_w-2)ue=chat_w-2;
   1383                 mvwchgat(win_chat,row,us,ue-us,A_UNDERLINE,C_URL,NULL);
   1384                 ustart=us+ulen;
   1385             }
   1386         }
   1387     }
   1388 
   1389     if (ch->scroll>0) {
   1390         char tag[16]; snprintf(tag,sizeof(tag),"^%d",ch->scroll);
   1391         wattron(win_chat,COLOR_PAIR(C_STATUS)|A_DIM);
   1392         mvwaddstr(win_chat,0,chat_w-(int)strlen(tag)-1,tag);
   1393         wattroff(win_chat,COLOR_PAIR(C_STATUS)|A_DIM);
   1394     }
   1395     wnoutrefresh(win_chat);
   1396 }
   1397 
   1398 static void draw_users(void) {
   1399     int H,W; getmaxyx(win_users,H,W); (void)W;
   1400     werase(win_users);
   1401     Channel *ch=&g_chans[g_active];
   1402     char hdr[32]; snprintf(hdr,sizeof(hdr)," USERS (%d)",ch->user_count);
   1403     wattron(win_users,COLOR_PAIR(C_BORDER)|A_BOLD);
   1404     mvwaddnstr(win_users,0,1,hdr,USER_W-2);
   1405     wattroff(win_users,COLOR_PAIR(C_BORDER)|A_BOLD);
   1406     for (int i=0; i<ch->user_count; i++) {
   1407         int y=i+2; if(y>=H-1) break;
   1408         const char *entry = ch->users[i];
   1409         char   mode  = entry[0];
   1410         const char *bare = entry+1;   /* nick without prefix */
   1411         /* colour the mode symbol */
   1412         if (mode=='@'||mode=='~'||mode=='&') {
   1413             wattron(win_users, COLOR_PAIR(C_MENTION)|A_BOLD);
   1414             mvwaddnstr(win_users,y,1,&mode,1);
   1415             wattroff(win_users, COLOR_PAIR(C_MENTION)|A_BOLD);
   1416         } else if (mode=='+'||mode=='%') {
   1417             wattron(win_users, COLOR_PAIR(C_ME)|A_BOLD);
   1418             mvwaddnstr(win_users,y,1,&mode,1);
   1419             wattroff(win_users, COLOR_PAIR(C_ME)|A_BOLD);
   1420         } else {
   1421             mvwaddch(win_users,y,1,' ');
   1422         }
   1423         /* nick in normal white */
   1424         wattron(win_users,COLOR_PAIR(C_CHAN));
   1425         mvwaddnstr(win_users,y,2,bare,USER_W-3);
   1426         wattroff(win_users,COLOR_PAIR(C_CHAN));
   1427     }
   1428     wnoutrefresh(win_users);
   1429 }
   1430 
   1431 static void draw_input(void) {
   1432     int iw; getmaxyx(win_input,(int){0},iw);
   1433     werase(win_input);
   1434     wbkgd(win_input,COLOR_PAIR(C_INPUT));
   1435 
   1436     /* draw "> " prompt */
   1437     wattron(win_input, COLOR_PAIR(C_BORDER)|A_BOLD);
   1438     mvwaddstr(win_input, 0, 0, "> ");
   1439     wattroff(win_input, COLOR_PAIR(C_BORDER)|A_BOLD);
   1440 
   1441     /* available width after prompt */
   1442     int pfx = 2;
   1443     int avail = iw - pfx - 1;
   1444     if (avail < 1) avail = 1;
   1445 
   1446     /* scroll text so cursor stays visible */
   1447     int disp = 0, cx = g_input_cur;
   1448     if (g_input_cur > avail) {
   1449         disp = g_input_cur - avail;
   1450         cx   = avail;
   1451     }
   1452 
   1453     wattron(win_input, COLOR_PAIR(C_INPUT));
   1454     mvwaddnstr(win_input, 0, pfx, g_input + disp, avail);
   1455     wattroff(win_input, COLOR_PAIR(C_INPUT));
   1456 
   1457     /* place terminal cursor — curs_set(2) gives blinking block on most terms */
   1458     wmove(win_input, 0, pfx + cx);
   1459     wnoutrefresh(win_input);
   1460 }
   1461 
   1462 static void draw_all(void) {
   1463     draw_status_bar(); draw_channels(); draw_chat();
   1464     draw_users(); draw_input(); doupdate();
   1465 }
   1466 
   1467 /* ── window setup ──────────────────────────────────────────────────────────── */
   1468 
   1469 static void init_colors(void) {
   1470     start_color(); use_default_colors();
   1471     init_pair(C_BORDER,      COLOR_CYAN,    -1);
   1472     init_pair(C_HEADER,      COLOR_BLACK,   COLOR_CYAN);
   1473     init_pair(C_ME,          COLOR_YELLOW,  -1);
   1474     init_pair(C_JOIN,        COLOR_BLACK+8, -1);
   1475     init_pair(C_MENTION,     COLOR_RED,     -1);
   1476     init_pair(C_STATUS,      COLOR_WHITE,   -1);
   1477     init_pair(C_INPUT,       COLOR_WHITE,   COLOR_BLACK);
   1478     init_pair(C_CHAN_SEL,    COLOR_BLACK,   COLOR_WHITE);
   1479     init_pair(C_CHAN,        COLOR_WHITE,   -1);
   1480     init_pair(C_UNREAD,      COLOR_YELLOW,  -1);
   1481     init_pair(C_MENTION_CHAN,COLOR_RED,     -1);
   1482     init_pair(C_URL,         COLOR_BLUE,    -1);
   1483     init_pair(C_ACTION,      COLOR_MAGENTA, -1);
   1484     init_pair(C_SERVER_HDR,  COLOR_CYAN,    -1);
   1485     static const int nick_cols[C_NICK_COUNT]={
   1486         COLOR_GREEN,COLOR_CYAN,COLOR_MAGENTA,COLOR_YELLOW,
   1487         COLOR_WHITE,COLOR_RED, COLOR_BLUE,   COLOR_GREEN
   1488     };
   1489     for (int i=0;i<C_NICK_COUNT;i++)
   1490         init_pair(C_NICK_BASE+i,nick_cols[i],-1);
   1491 }
   1492 
   1493 static void build_windows(void) {
   1494     int H,W; getmaxyx(stdscr,H,W);
   1495     int chat_w=W-CHAN_W-USER_W, chat_h=H-3;
   1496     if(win_chan)   delwin(win_chan);
   1497     if(win_chat)   delwin(win_chat);
   1498     if(win_users)  delwin(win_users);
   1499     if(win_input)  delwin(win_input);
   1500     if(win_status) delwin(win_status);
   1501     win_status=newwin(1,     W,      0,  0);
   1502     win_chan   =newwin(H,     CHAN_W, 0,  0);
   1503     win_chat   =newwin(chat_h,chat_w,1,  CHAN_W);
   1504     win_users  =newwin(H,     USER_W,0,  CHAN_W+chat_w);
   1505     win_input  =newwin(1,     chat_w,H-1,CHAN_W);
   1506 }
   1507 
   1508 /* ── event handler ─────────────────────────────────────────────────────────── */
   1509 
   1510 static void do_rejoin_channels(int si) {
   1511     /* always join every configured autojoin channel */
   1512     for (int j = 0; j < g_srv[si].autojoin_count; j++)
   1513         srv_sendf(si, "JOIN %s", g_srv[si].autojoin[j]);
   1514 
   1515     /* also rejoin any channels open from a previous session that aren't
   1516        in the autojoin list (e.g. channels joined manually before disconnect) */
   1517     for (int i = 0; i < g_chan_count; i++) {
   1518         if (g_chans[i].srv != si || g_chans[i].name[0] != '#') continue;
   1519         int already = 0;
   1520         for (int j = 0; j < g_srv[si].autojoin_count; j++)
   1521             if (strcasecmp(g_srv[si].autojoin[j], g_chans[i].name) == 0)
   1522                 { already = 1; break; }
   1523         if (!already)
   1524             srv_sendf(si, "JOIN %s", g_chans[i].name);
   1525     }
   1526 }
   1527 
   1528 static void handle_event(const Event *ev) {
   1529     int si=ev->srv;
   1530     Server *s=&g_srv[si];
   1531 
   1532     switch (ev->type) {
   1533 
   1534     case EV_CONNECTED:
   1535         srv_status_msg(si,"Connected.");
   1536         do_rejoin_channels(si);
   1537         break;
   1538 
   1539     case EV_STATUS:
   1540         srv_status_msg(si,ev->text);
   1541         break;
   1542 
   1543     case EV_ERROR:
   1544         srv_status_fmt(si,"[ERR] %s",ev->text);
   1545         break;
   1546 
   1547     case EV_RECONNECT:
   1548         if (!s->connected) schedule_reconnect(si);
   1549         break;
   1550 
   1551     case EV_SERVER_TEXT:
   1552         if (ev->chan[0]) {
   1553             int ci=chan_find(si,ev->chan);
   1554             if (ci<0) ci=chan_add(si,ev->chan);
   1555             chan_addline(&g_chans[ci],F_STATUS,ev->text);
   1556             if (ci==g_active) g_chans[ci].unread=0;
   1557         } else {
   1558             srv_status_msg(si,ev->text);
   1559         }
   1560         break;
   1561 
   1562     case EV_RAW:
   1563         srv_status_msg(si,ev->text);
   1564         break;
   1565 
   1566     case EV_PRIVMSG: {
   1567         if (is_ignored(ev->nick)) break;
   1568         const char *target=ev->chan, *text=ev->text;
   1569         const char *dest=(target[0]=='#')?target:ev->nick;
   1570         int ci=chan_find(si,dest); if(ci<0) ci=chan_add(si,dest);
   1571 
   1572         if (text[0]=='\x01'&&strncmp(text+1,"ACTION",6)==0) {
   1573             const char *act=text+8;
   1574             char msg[MAX_LINE];
   1575             int alen=strlen(act); if(alen>0&&act[alen-1]=='\x01')alen--;
   1576             snprintf(msg,sizeof(msg),"* %s %.*s",ev->nick,alen,act);
   1577             int flag=(strcasestr(msg,s->nick)!=NULL)?F_MENTION:F_ACTION;
   1578             chan_addline(&g_chans[ci],flag,msg);
   1579             if(ci!=g_active&&flag==F_MENTION) g_chans[ci].mention=1;
   1580             if(ci==g_active) g_chans[ci].unread=0;
   1581             break;
   1582         }
   1583         char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<%s> %s",ev->nick,text);
   1584         int flag;
   1585         if (strcmp(ev->nick,s->nick)==0) flag=F_ME;
   1586         else if (strcasestr(text,s->nick)!=NULL) flag=F_MENTION;
   1587         else flag=F_MSG;
   1588         chan_addline(&g_chans[ci],flag,msg);
   1589         if(ci!=g_active&&flag==F_MENTION) g_chans[ci].mention=1;
   1590         if(ci==g_active) g_chans[ci].unread=0;
   1591         break;
   1592     }
   1593 
   1594     case EV_JOIN: {
   1595         int ci=chan_find(si,ev->chan); if(ci<0) ci=chan_add(si,ev->chan);
   1596         if (strcmp(ev->nick,s->nick)==0) {
   1597             g_chans[ci].user_count=0;
   1598             g_active=ci;
   1599             g_chans[ci].unread=0; g_chans[ci].mention=0;
   1600             srv_status_fmt(si,"Joined %s",ev->chan);
   1601         } else {
   1602             char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"-> %s joined",ev->nick);
   1603             chan_addline(&g_chans[ci],F_JOIN,msg);
   1604             chan_adduser(&g_chans[ci], 0, ev->nick);
   1605         }
   1606         break;
   1607     }
   1608 
   1609     case EV_PART: {
   1610         int ci=chan_find(si,ev->chan); if(ci<0) break;
   1611         char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<- %s parted (%s)",ev->nick,ev->text);
   1612         chan_addline(&g_chans[ci],F_JOIN,msg);
   1613         if (strcmp(ev->nick,s->nick)==0) chan_remove(ci);
   1614         else chan_removeuser(&g_chans[ci],ev->nick);
   1615         break;
   1616     }
   1617 
   1618     case EV_QUIT_MSG:
   1619         for (int i=0;i<g_chan_count;i++) {
   1620             if (g_chans[i].srv!=si) continue;
   1621             int found=0;
   1622             for (int j=0;j<g_chans[i].user_count;j++)
   1623                 if (strcasecmp(g_chans[i].users[j],ev->nick)==0){found=1;break;}
   1624             if (found) {
   1625                 char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<- %s quit (%s)",ev->nick,ev->text);
   1626                 chan_addline(&g_chans[i],F_JOIN,msg);
   1627                 chan_removeuser(&g_chans[i],ev->nick);
   1628             }
   1629         }
   1630         break;
   1631 
   1632     case EV_NICK_CHANGE:
   1633         if (strcmp(ev->nick,s->nick)==0) {
   1634             strncpy(s->nick,ev->extra,MAX_NICK-1);
   1635             srv_status_fmt(si,"You are now known as %s",s->nick);
   1636         }
   1637         for (int i=0;i<g_chan_count;i++) {
   1638             if (g_chans[i].srv!=si) continue;
   1639             for (int j=0;j<g_chans[i].user_count;j++) {
   1640                 /* skip stored mode prefix when comparing */
   1641                 const char *sn=g_chans[i].users[j];
   1642                 if (*sn=='@'||*sn=='+'||*sn=='~'||*sn=='&'||*sn=='%'||*sn==' ') sn++;
   1643                 if (strcasecmp(sn,ev->nick)==0) {
   1644                     char mode=g_chans[i].users[j][0];
   1645                     g_chans[i].users[j][0]=mode;
   1646                     strncpy(g_chans[i].users[j]+1,ev->extra,MAX_NICK-2);
   1647                     char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"~ %s is now %s",ev->nick,ev->extra);
   1648                     chan_addline(&g_chans[i],F_JOIN,msg);
   1649                     break;
   1650                 }
   1651             }
   1652         }
   1653         break;
   1654 
   1655     case EV_NAMES: {
   1656         int ci=chan_find(si,ev->chan); if(ci<0) break;
   1657         /* first nick of a fresh NAMES reply — wipe the stale list */
   1658         if (!g_chans[ci].names_pending) {
   1659             g_chans[ci].user_count   = 0;
   1660             g_chans[ci].names_pending = 1;
   1661         }
   1662         chan_adduser(&g_chans[ci], ev->extra[0], ev->nick);
   1663         chan_sort_users(&g_chans[ci]);
   1664         break;
   1665     }
   1666 
   1667     case EV_KICK: {
   1668         int ci=chan_find(si,ev->chan); if(ci<0) break;
   1669         char msg[MAX_LINE];
   1670         snprintf(msg,sizeof(msg),"X %s was kicked by %s (%s)",ev->extra,ev->nick,ev->text);
   1671         chan_addline(&g_chans[ci],F_JOIN,msg);
   1672         chan_removeuser(&g_chans[ci],ev->extra);
   1673         break;
   1674     }
   1675 
   1676     case EV_TOPIC: {
   1677         int ci=chan_find(si,ev->chan); if(ci<0) break;
   1678         strip_irc_fmt(ev->text, g_chans[ci].topic, MAX_LINE);
   1679         break;
   1680     }
   1681 
   1682     case EV_NAMES_END: {
   1683         int ci=chan_find(si,ev->chan); if(ci<0) break;
   1684         g_chans[ci].names_pending = 0;
   1685         break;
   1686     }
   1687 
   1688     }
   1689 }
   1690 
   1691 /* ── tab completion ────────────────────────────────────────────────────────── */
   1692 
   1693 static void tab_reset(void) { g_tab_active=0; g_tab_count=0; g_tab_idx=0; }
   1694 
   1695 static void tab_complete(void) {
   1696     if (g_active<0||g_active>=g_chan_count) return;
   1697     Channel *ch=&g_chans[g_active];
   1698     if (!g_tab_active) {
   1699         int ws=g_input_cur;
   1700         while (ws>0&&g_input[ws-1]!=' '&&g_input[ws-1]!='\t') ws--;
   1701         char prefix[MAX_NICK]; int plen=g_input_cur-ws;
   1702         if (plen<=0) return;
   1703         strncpy(prefix,g_input+ws,plen); prefix[plen]='\0';
   1704         char *pp=prefix;
   1705         while(*pp=='@'||*pp=='+'||*pp=='~'||*pp=='&'||*pp=='%') pp++;
   1706         if (!*pp) return;
   1707         g_tab_count=0;
   1708         for (int i=0;i<ch->user_count;i++) {
   1709             const char *bare=ch->users[i]+1;  /* skip mode prefix */
   1710             if (str_icase_starts(bare,pp))
   1711                 strncpy(g_tab_matches[g_tab_count++],bare,MAX_NICK-1);
   1712         }
   1713         if (!g_tab_count) return;
   1714         g_tab_idx=0; g_tab_word_start=ws; g_tab_active=1;
   1715     } else {
   1716         g_tab_idx=(g_tab_idx+1)%g_tab_count;
   1717     }
   1718     const char *nick=g_tab_matches[g_tab_idx];
   1719     char repl[MAX_NICK+3];
   1720     snprintf(repl,sizeof(repl),g_tab_word_start==0?"%s: ":"%s ",nick);
   1721     int rlen=strlen(repl);
   1722     char newbuf[MAX_INPUT];
   1723     int tail=g_input_len-g_input_cur; if(tail<0)tail=0;
   1724     memcpy(newbuf,g_input,g_tab_word_start);
   1725     memcpy(newbuf+g_tab_word_start,repl,rlen);
   1726     memcpy(newbuf+g_tab_word_start+rlen,g_input+g_input_cur,tail);
   1727     int newlen=g_tab_word_start+rlen+tail;
   1728     if(newlen>=MAX_INPUT)newlen=MAX_INPUT-1;
   1729     memcpy(g_input,newbuf,newlen); g_input[newlen]='\0';
   1730     g_input_len=newlen; g_input_cur=g_tab_word_start+rlen;
   1731 }
   1732 
   1733 /* ── command processing ────────────────────────────────────────────────────── */
   1734 
   1735 /* current server index = server of the active channel */
   1736 static int active_srv(void) {
   1737     if (g_active>=0&&g_active<g_chan_count) return g_chans[g_active].srv;
   1738     return 0;
   1739 }
   1740 
   1741 static void process_input(const char *text) {
   1742     int si=active_srv();
   1743     Server *s=&g_srv[si];
   1744 
   1745     if (text[0]!='/') {
   1746         if (g_active<0) { srv_status_msg(si,"Use /join #channel first."); return; }
   1747         const char *cn=g_chans[g_active].name;
   1748         if (cn[0]=='*') { srv_status_msg(si,"Use /join #channel first."); return; }
   1749         if (!s->connected) { srv_status_msg(si,"[not connected]"); return; }
   1750         srv_sendf(si,"PRIVMSG %s :%s",cn,text);
   1751         char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<%s> %s",s->nick,text);
   1752         chan_addline(&g_chans[g_active],F_ME,msg);
   1753         g_chans[g_active].unread=0;
   1754         return;
   1755     }
   1756 
   1757     char cmd[64],arg[MAX_INPUT];
   1758     cmd[0]='\0'; arg[0]='\0';
   1759     const char *sp=strchr(text+1,' ');
   1760     if (sp) {
   1761         int cl=(int)(sp-text); if(cl>=(int)sizeof(cmd))cl=sizeof(cmd)-1;
   1762         memcpy(cmd,text,cl); cmd[cl]='\0';
   1763         const char *a=sp+1; while(*a==' ')a++;
   1764         strncpy(arg,a,MAX_INPUT-1);
   1765     } else strncpy(cmd,text,sizeof(cmd)-1);
   1766     for (char *c=cmd;*c;c++) *c=tolower((unsigned char)*c);
   1767 
   1768     if (strcmp(cmd,"/join")==0) {
   1769         char ch[MAX_CHAN]; strncpy(ch,arg[0]?arg:g_srv[si].autojoin_count?g_srv[si].autojoin[0]:"#general",MAX_CHAN-1);
   1770         if(ch[0]!='#'){memmove(ch+1,ch,strlen(ch)+1);ch[0]='#';}
   1771         chan_add(si,ch);
   1772         if(s->connected) srv_sendf(si,"JOIN %s",ch);
   1773         else srv_status_msg(si,"[not connected]");
   1774 
   1775     } else if (strcmp(cmd,"/part")==0) {
   1776         const char *ch=arg[0]?arg:g_chans[g_active].name;
   1777         if(s->connected) srv_sendf(si,"PART %s :bye",ch);
   1778         else srv_status_msg(si,"[not connected]");
   1779 
   1780     } else if (strcmp(cmd,"/cycle")==0) {
   1781         const char *ch=arg[0]?arg:g_chans[g_active].name;
   1782         if(ch[0]!='#'){srv_status_msg(si,"Not in a channel.");return;}
   1783         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1784         srv_sendf(si,"PART %s :cycling",ch); srv_sendf(si,"JOIN %s",ch);
   1785 
   1786     } else if (strcmp(cmd,"/nick")==0) {
   1787         if(!arg[0]) {
   1788             srv_status_fmt(si,"Your nick is: %s",s->nick);
   1789         } else {
   1790             if(s->connected) srv_sendf(si,"NICK %s",arg);
   1791             else strncpy(s->nick,arg,MAX_NICK-1);
   1792             srv_status_fmt(si,"Nick change requested: %s -> %s",s->nick,arg);
   1793         }
   1794 
   1795     } else if (strcmp(cmd,"/msg")==0) {
   1796         char tgt[MAX_NICK],msg[MAX_LINE];
   1797         if(sscanf(arg,"%63s %[^\n]",tgt,msg)==2) {
   1798             chan_add(si,tgt);
   1799             if(s->connected) srv_sendf(si,"PRIVMSG %s :%s",tgt,msg);
   1800             int ci=chan_find(si,tgt);
   1801             if(ci>=0){ char full[MAX_LINE]; snprintf(full,sizeof(full),"<%s> %s",s->nick,msg); chan_addline(&g_chans[ci],F_ME,full); }
   1802         }
   1803 
   1804     } else if (strcmp(cmd,"/me")==0) {
   1805         const char *cn=g_chans[g_active].name;
   1806         if(cn[0]=='*'){srv_status_msg(si,"Not in a channel.");return;}
   1807         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1808         srv_sendf(si,"PRIVMSG %s :\x01""ACTION %s\x01",cn,arg);
   1809         char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"* %s %s",s->nick,arg);
   1810         chan_addline(&g_chans[g_active],F_ACTION,msg);
   1811 
   1812     } else if (strcmp(cmd,"/notice")==0) {
   1813         char tgt[MAX_NICK],msg[MAX_LINE];
   1814         if(sscanf(arg,"%63s %[^\n]",tgt,msg)==2) {
   1815             if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1816             srv_sendf(si,"NOTICE %s :%s",tgt,msg);
   1817             srv_status_fmt(si,"->%s<- %s",tgt,msg);
   1818         } else srv_status_msg(si,"Usage: /notice <target> <message>");
   1819 
   1820     } else if (strcmp(cmd,"/ctcp")==0) {
   1821         char tgt[MAX_NICK],ctcp[MAX_LINE];
   1822         if(sscanf(arg,"%63s %[^\n]",tgt,ctcp)>=1) {
   1823             if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1824             for(char *c=ctcp;*c&&*c!=' ';c++) *c=toupper((unsigned char)*c);
   1825             srv_sendf(si,"PRIVMSG %s :\x01%s\x01",tgt,ctcp);
   1826             srv_status_fmt(si,"[ctcp] %s -> %s",ctcp,tgt);
   1827         } else srv_status_msg(si,"Usage: /ctcp <nick> <command>");
   1828 
   1829     } else if (strcmp(cmd,"/names")==0) {
   1830         const char *ch=arg[0]?arg:g_chans[g_active].name;
   1831         if(ch[0]=='*'){srv_status_msg(si,"Not in a channel.");return;}
   1832         if(s->connected) srv_sendf(si,"NAMES %s",ch);
   1833         else {
   1834             int ci=chan_find(si,ch); if(ci<0){srv_status_msg(si,"Channel not found.");return;}
   1835             srv_status_fmt(si,"[names] %s (%d users):",ch,g_chans[ci].user_count);
   1836             char line[MAX_LINE]; line[0]='\0';
   1837             for(int i=0;i<g_chans[ci].user_count;i++){
   1838                 if(strlen(line)+strlen(g_chans[ci].users[i])+2>60){
   1839                     srv_status_fmt(si,"[names]  %s",line); line[0]='\0';
   1840                 }
   1841                 if(line[0]) strncat(line," ",MAX_LINE-1);
   1842                 strncat(line,g_chans[ci].users[i],MAX_LINE-1);
   1843             }
   1844             if(line[0]) srv_status_fmt(si,"[names]  %s",line);
   1845         }
   1846 
   1847     } else if (strcmp(cmd,"/topic")==0) {
   1848         const char *ch=g_chans[g_active].name;
   1849         if(ch[0]=='*'){srv_status_msg(si,"Not in a channel.");return;}
   1850         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1851         if(arg[0]) srv_sendf(si,"TOPIC %s :%s",ch,arg);
   1852         else srv_sendf(si,"TOPIC %s",ch);
   1853 
   1854     } else if (strcmp(cmd,"/whois")==0) {
   1855         if(!arg[0]){srv_status_msg(si,"Usage: /whois <nick>");return;}
   1856         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1857         srv_sendf(si,"WHOIS %s",arg);
   1858 
   1859     } else if (strcmp(cmd,"/who")==0) {
   1860         const char *tgt=arg[0]?arg:g_chans[g_active].name;
   1861         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1862         srv_sendf(si,"WHO %s",tgt);
   1863 
   1864     } else if (strcmp(cmd,"/list")==0) {
   1865         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1866         srv_status_msg(si,"[list] Requesting channel list...");
   1867         if(arg[0]) srv_sendf(si,"LIST %s",arg); else srv_send_raw(si,"LIST");
   1868 
   1869     } else if (strcmp(cmd,"/away")==0) {
   1870         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1871         if(arg[0]) srv_sendf(si,"AWAY :%s",arg); else srv_send_raw(si,"AWAY");
   1872 
   1873     } else if (strcmp(cmd,"/back")==0) {
   1874         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1875         srv_send_raw(si,"AWAY");
   1876 
   1877     } else if (strcmp(cmd,"/invite")==0) {
   1878         char tgt[MAX_NICK],ch[MAX_CHAN]; ch[0]='\0';
   1879         if(sscanf(arg,"%63s %63s",tgt,ch)<1){srv_status_msg(si,"Usage: /invite <nick> [#chan]");return;}
   1880         if(!ch[0]) strncpy(ch,g_chans[g_active].name,MAX_CHAN-1);
   1881         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1882         srv_sendf(si,"INVITE %s %s",tgt,ch);
   1883 
   1884     } else if (strcmp(cmd,"/kick")==0) {
   1885         char tgt[MAX_NICK],reason[MAX_LINE]; reason[0]='\0';
   1886         if(sscanf(arg,"%63s %[^\n]",tgt,reason)<1){srv_status_msg(si,"Usage: /kick <nick> [reason]");return;}
   1887         const char *ch=g_chans[g_active].name;
   1888         if(ch[0]!='#'){srv_status_msg(si,"Not in a channel.");return;}
   1889         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1890         if(reason[0]) srv_sendf(si,"KICK %s %s :%s",ch,tgt,reason);
   1891         else srv_sendf(si,"KICK %s %s",ch,tgt);
   1892 
   1893     } else if (strcmp(cmd,"/mode")==0) {
   1894         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1895         if(!arg[0]) srv_sendf(si,"MODE %s",g_chans[g_active].name);
   1896         else srv_sendf(si,"MODE %s",arg);
   1897 
   1898     } else if (strcmp(cmd,"/raw")==0||strcmp(cmd,"/quote")==0) {
   1899         if(!arg[0]){srv_status_msg(si,"Usage: /raw <irc line>");return;}
   1900         if(!s->connected){srv_status_msg(si,"[not connected]");return;}
   1901         srv_send_raw(si,arg);
   1902         srv_status_fmt(si,">> %s",arg);
   1903 
   1904     } else if (strcmp(cmd,"/server")==0) {
   1905         /* /server host [port] — add a new server connection */
   1906         char newhost[MAX_HOST]; int newport=6697;
   1907         if(sscanf(arg,"%255s %d",newhost,&newport)<1){srv_status_msg(si,"Usage: /server <host> [port]");return;}
   1908         int nsi=srv_alloc();
   1909         if(nsi<0){srv_status_msg(si,"Max servers reached.");return;}
   1910         strncpy(g_srv[nsi].host,newhost,MAX_HOST-1);
   1911         g_srv[nsi].port=newport;
   1912         /* inherit nick from current server */
   1913         strncpy(g_srv[nsi].nick,s->nick,MAX_NICK-1);
   1914         chan_add(nsi,"*status*");
   1915         start_net_thread(nsi);
   1916         srv_status_fmt(nsi,"Connecting to %s:%d...",newhost,newport);
   1917 
   1918     } else if (strcmp(cmd,"/connect")==0) {
   1919         g_srv[si].net_stop=1;
   1920         start_net_thread(si);
   1921 
   1922     } else if (strcmp(cmd,"/ignore")==0) {
   1923         if(!arg[0]) {
   1924             if(!g_ignore_count){srv_status_msg(si,"Ignore list is empty.");return;}
   1925             srv_status_msg(si,"Ignored nicks:");
   1926             for(int i=0;i<g_ignore_count;i++) srv_status_fmt(si,"  %s",g_ignore[i]);
   1927             return;
   1928         }
   1929         ignore_add(arg); srv_status_fmt(si,"Ignoring %s",arg);
   1930 
   1931     } else if (strcmp(cmd,"/unignore")==0) {
   1932         if(is_ignored(arg)){ignore_remove(arg);srv_status_fmt(si,"Unignored %s",arg);}
   1933         else srv_status_fmt(si,"%s is not ignored",arg);
   1934 
   1935     } else if (strcmp(cmd,"/clear")==0) {
   1936         g_chans[g_active].line_count=0;
   1937         g_chans[g_active].line_head=0;
   1938         g_chans[g_active].scroll=0;
   1939 
   1940     } else if (strcmp(cmd,"/quit")==0) {
   1941         for (int i=0;i<g_srv_count;i++)
   1942             if(g_srv[i].connected) srv_sendf(i,"QUIT :%s",arg[0]?arg:"Goodbye");
   1943         endwin(); exit(0);
   1944 
   1945     } else if (strcmp(cmd,"/help")==0) {
   1946         static const char *h[]={
   1947             "Commands:",
   1948             "  /join #chan          join channel",
   1949             "  /part [#chan]        leave channel",
   1950             "  /cycle [#chan]       part and rejoin",
   1951             "  /nick NAME           change nick",
   1952             "  /msg NICK TEXT       private message",
   1953             "  /me TEXT             action message",
   1954             "  /notice TGT TEXT     send notice",
   1955             "  /ctcp NICK CMD       CTCP request",
   1956             "  /names [#chan]       list users",
   1957             "  /topic [text]        get/set topic",
   1958             "  /whois NICK          whois lookup",
   1959             "  /who [target]        who query",
   1960             "  /list [pattern]      list channels",
   1961             "  /away [msg]          set away",
   1962             "  /back                clear away",
   1963             "  /invite NICK [chan]  invite to channel",
   1964             "  /kick NICK [reason]  kick from channel",
   1965             "  /mode [target] [m]   get/set mode",
   1966             "  /server HOST [port]  connect to new server",
   1967             "  /raw <line>          send raw IRC line",
   1968             "  /ignore [nick]       ignore (no arg = list)",
   1969             "  /unignore NICK       unignore nick",
   1970             "  /clear               clear scrollback",
   1971             "  /connect             reconnect current server",
   1972             "  /quit [msg]          quit",
   1973             "Keys:",
   1974             "  Tab                  nick completion",
   1975             "  Ctrl+N/P             next/prev channel",
   1976             "  PgUp/PgDn            scroll chat",
   1977             "  Ctrl+W               delete word left",
   1978             "  Up/Down              input history",
   1979             NULL
   1980         };
   1981         for(int i=0;h[i];i++) srv_status_msg(si,h[i]);
   1982 
   1983     } else {
   1984         srv_status_fmt(si,"Unknown command: %s  (/help for list)",cmd);
   1985     }
   1986 }
   1987 
   1988 /* ── history ───────────────────────────────────────────────────────────────── */
   1989 
   1990 static void hist_push(const char *text) {
   1991     if(g_hist_count<MAX_HIST_LINES) strncpy(g_hist[g_hist_count++].text,text,MAX_INPUT-1);
   1992     else { memmove(g_hist,g_hist+1,(MAX_HIST_LINES-1)*sizeof(HistEntry)); strncpy(g_hist[MAX_HIST_LINES-1].text,text,MAX_INPUT-1); }
   1993 }
   1994 
   1995 /* ── key handler ───────────────────────────────────────────────────────────── */
   1996 
   1997 static void handle_key(int key) {
   1998     if (key==KEY_RESIZE) { clear(); build_windows(); return; }
   1999     if (key=='\t') { tab_complete(); return; }
   2000     tab_reset();
   2001 
   2002     Channel *ch=&g_chans[g_active];
   2003 
   2004     if (key==14) { g_active=chan_next(g_active,1); g_chans[g_active].unread=0; g_chans[g_active].mention=0; return; }
   2005     if (key==16) { g_active=chan_next(g_active,-1); g_chans[g_active].unread=0; g_chans[g_active].mention=0; return; }
   2006 
   2007     if (key==KEY_PPAGE) { int ch2; getmaxyx(win_chat,ch2,(int){0}); ch->scroll+=ch2/2; return; }
   2008     if (key==KEY_NPAGE) { int ch2; getmaxyx(win_chat,ch2,(int){0}); ch->scroll-=ch2/2; if(ch->scroll<0)ch->scroll=0; return; }
   2009 
   2010     if (key=='\n'||key=='\r'||key==KEY_ENTER) {
   2011         if(g_input_len>0) {
   2012             g_input[g_input_len]='\0';
   2013             char *p=g_input; while(*p==' ')p++;
   2014             char *e=g_input+g_input_len-1; while(e>p&&*e==' ')*e--='\0';
   2015             if(*p) { hist_push(p); g_hist_pos=-1; process_input(p); }
   2016         }
   2017         g_input[0]='\0'; g_input_len=0; g_input_cur=0; return;
   2018     }
   2019 
   2020     if (key==127||key==KEY_BACKSPACE||key==8) {
   2021         if(g_input_cur>0){ memmove(g_input+g_input_cur-1,g_input+g_input_cur,g_input_len-g_input_cur+1); g_input_cur--; g_input_len--; }
   2022         return;
   2023     }
   2024 
   2025     if (key==23) { /* Ctrl+W */
   2026         int cur=g_input_cur;
   2027         while(cur>0&&g_input[cur-1]==' ') cur--;
   2028         while(cur>0&&g_input[cur-1]!=' ') cur--;
   2029         int del=g_input_cur-cur;
   2030         memmove(g_input+cur,g_input+g_input_cur,g_input_len-g_input_cur+1);
   2031         g_input_len-=del; g_input_cur=cur; return;
   2032     }
   2033 
   2034     if (key==KEY_DC) { if(g_input_cur<g_input_len){ memmove(g_input+g_input_cur,g_input+g_input_cur+1,g_input_len-g_input_cur); g_input_len--; } return; }
   2035 
   2036     if (key==KEY_LEFT  &&g_input_cur>0)           { g_input_cur--; return; }
   2037     if (key==KEY_RIGHT &&g_input_cur<g_input_len) { g_input_cur++; return; }
   2038     if (key==KEY_HOME) { g_input_cur=0; return; }
   2039     if (key==KEY_END)  { g_input_cur=g_input_len; return; }
   2040 
   2041     if (key==KEY_UP) {
   2042         if(g_hist_count>0){ if(g_hist_pos<g_hist_count-1)g_hist_pos++; const char *t=g_hist[g_hist_count-1-g_hist_pos].text; strncpy(g_input,t,MAX_INPUT-1); g_input_len=strlen(g_input); g_input_cur=g_input_len; }
   2043         return;
   2044     }
   2045     if (key==KEY_DOWN) {
   2046         if(g_hist_pos>0){ g_hist_pos--; const char *t=g_hist[g_hist_count-1-g_hist_pos].text; strncpy(g_input,t,MAX_INPUT-1); g_input_len=strlen(g_input); g_input_cur=g_input_len; }
   2047         else { g_hist_pos=-1; g_input[0]='\0'; g_input_len=0; g_input_cur=0; }
   2048         return;
   2049     }
   2050 
   2051     if (key>=32&&key<256) {
   2052         if(g_input_len<MAX_INPUT-1){ memmove(g_input+g_input_cur+1,g_input+g_input_cur,g_input_len-g_input_cur+1); g_input[g_input_cur++]=(char)key; g_input_len++; }
   2053         return;
   2054     }
   2055 }
   2056 
   2057 /* ── main ──────────────────────────────────────────────────────────────────── */
   2058 
   2059 static void usage(const char *prog) {
   2060     fprintf(stderr,
   2061         "Usage: %s [options]\n"
   2062         "  --host HOST        server hostname\n"
   2063         "  --port PORT        port (default 6697)\n"
   2064         "  --nick NICK        nickname\n"
   2065         "  --channel CHAN     channel(s) to join, comma-separated\n"
   2066         "  --tls / --no-tls   TLS on/off (default on)\n"
   2067         "  --sasl-user USER   SASL username\n"
   2068         "  --sasl-pass PASS   SASL password\n"
   2069         "  --config FILE      config file\n"
   2070         "\n"
   2071         "Multiple servers via ~/.sirc [server] blocks.\n", prog);
   2072 }
   2073 
   2074 int main(int argc, char **argv) {
   2075     const char *config_path=NULL;
   2076     for(int i=1;i<argc-1;i++) if(strcmp(argv[i],"--config")==0){config_path=argv[i+1];break;}
   2077     load_config(config_path);
   2078 
   2079     /* CLI server (only if --host given, or if no servers loaded yet) */
   2080     const char *cli_host=NULL;
   2081     int cli_port=-1; int cli_tls=-1;
   2082     const char *cli_nick=NULL, *cli_chan=NULL, *cli_su=NULL, *cli_sp=NULL;
   2083     for(int i=1;i<argc;i++) {
   2084         if      (strcmp(argv[i],"--host")==0     &&i+1<argc) cli_host=argv[++i];
   2085         else if (strcmp(argv[i],"--port")==0     &&i+1<argc) cli_port=atoi(argv[++i]);
   2086         else if (strcmp(argv[i],"--nick")==0     &&i+1<argc) cli_nick=argv[++i];
   2087         else if (strcmp(argv[i],"--channel")==0  &&i+1<argc) cli_chan=argv[++i];
   2088         else if (strcmp(argv[i],"--tls")==0)      cli_tls=1;
   2089         else if (strcmp(argv[i],"--no-tls")==0)   cli_tls=0;
   2090         else if (strcmp(argv[i],"--sasl-user")==0&&i+1<argc) cli_su=argv[++i];
   2091         else if (strcmp(argv[i],"--sasl-pass")==0&&i+1<argc) cli_sp=argv[++i];
   2092         else if (strcmp(argv[i],"--config")==0)   i++;
   2093         else if (strcmp(argv[i],"--help")==0)     { usage(argv[0]); return 0; }
   2094     }
   2095 
   2096     /* if CLI gave a host, either override first server or add new one */
   2097     if (cli_host) {
   2098         int si;
   2099         if (g_srv_count==0) si=srv_alloc();
   2100         else si=0;
   2101         strncpy(g_srv[si].host,cli_host,MAX_HOST-1);
   2102         if(cli_port>0) g_srv[si].port=cli_port;
   2103         if(cli_tls>=0) g_srv[si].use_tls=cli_tls;
   2104         if(cli_chan)   srv_add_autojoin(si,cli_chan);
   2105         if(cli_su)     strncpy(g_srv[si].sasl_user,cli_su,MAX_NICK-1);
   2106         if(cli_sp)     strncpy(g_srv[si].sasl_pass,cli_sp,255);
   2107         if(cli_nick)   strncpy(g_srv[si].nick,cli_nick,MAX_NICK-1);
   2108     } else if (g_srv_count==0) {
   2109         /* no config, no --host: add default server */
   2110         srv_alloc();
   2111     }
   2112 
   2113     /* apply --nick / --channel / --tls to all servers that don't have
   2114        their own value set (covers the no-host case) */
   2115     for (int i=0; i<g_srv_count; i++) {
   2116         if (cli_nick && !cli_host) strncpy(g_srv[i].nick, cli_nick, MAX_NICK-1);
   2117         if (cli_tls>=0 && !cli_host) g_srv[i].use_tls = cli_tls;
   2118         if (cli_chan && !cli_host) srv_add_autojoin(i, cli_chan);
   2119     }
   2120 
   2121     /* create status channels */
   2122     for(int i=0;i<g_srv_count;i++) chan_add(i,"*status*");
   2123 
   2124     /* event pipe */
   2125     if(pipe(g_evpipe)<0){perror("pipe");return 1;}
   2126     fcntl(g_evpipe[1],F_SETFL,O_NONBLOCK);
   2127     fcntl(g_evpipe[0],F_SETFL,O_NONBLOCK);
   2128     signal(SIGPIPE,SIG_IGN);
   2129 
   2130     initscr(); raw(); noecho();
   2131     keypad(stdscr,TRUE); nodelay(stdscr,TRUE); curs_set(2);
   2132     init_colors(); build_windows();
   2133 
   2134     /* start all server threads */
   2135     for(int i=0;i<g_srv_count;i++) start_net_thread(i);
   2136 
   2137     while(1) {
   2138         Event ev;
   2139         while(read(g_evpipe[0],&ev,sizeof(Event))==(ssize_t)sizeof(Event))
   2140             handle_event(&ev);
   2141         draw_all();
   2142         int key=getch();
   2143         if(key!=ERR) handle_key(key);
   2144         struct timespec ts={0,20000000};
   2145         nanosleep(&ts,NULL);
   2146     }
   2147     endwin(); return 0;
   2148 }