/* * sirc.c — multi-server terminal IRC client * * Usage: sirc [options] * * Config (~/.sirc): * # Default server (applied when no [server] block precedes it) * nick = mynick * ignore = badbot,spammer * * [server] * host = irc.libera.chat * port = 6697 * nick = mynick * channel = #python,#linux * tls = true * sasl_user = mynick * sasl_pass = hunter2 * * [server] * host = irc.oftc.net * port = 6697 * tls = true * channel = #debian * * CLI adds one server on top of whatever the config defines: * --host / --port / --nick / --channel / --tls / --no-tls * --sasl-user / --sasl-pass / --config * * Commands: * /join #chan join channel (on current server) * /part [#chan] leave channel * /cycle [#chan] part and rejoin * /nick NAME change nick * /msg NICK TEXT private message * /me TEXT action * /notice TGT TEXT send notice * /ctcp NICK CMD CTCP request * /names [#chan] list users * /topic [text] get/set topic * /whois NICK whois lookup * /who [target] who query * /list [pattern] list channels on server * /away [msg] set away * /back clear away * /invite NICK [chan] invite to channel * /kick NICK [reason] kick from channel * /mode [target] [m] get/set mode * /server HOST [port] connect to new server * /raw send raw IRC line (alias /quote) * /ignore [nick] ignore nick (no arg = list) * /unignore NICK unignore nick * /clear clear scrollback * /connect reconnect current server * /quit [msg] disconnect and exit * /help show help * * Keys: * Tab nick completion (cycle) * Ctrl+N/P next/prev channel (skips server headers) * PgUp/PgDn scroll chat * Ctrl+W delete word left * Up/Down input history */ #define _POSIX_C_SOURCE 200809L #define _DEFAULT_SOURCE #define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* ── constants ─────────────────────────────────────────────────────────────── */ #define MAX_NICK 64 #define MAX_CHAN 64 #define MAX_HOST 256 #define MAX_LINE 512 #define MAX_INPUT 480 #define MAX_HISTORY 500 #define MAX_SERVERS 8 #define MAX_CHANS_TOTAL 128 /* across all servers */ #define MAX_USERS 512 #define MAX_IGNORE 64 #define MAX_HIST_LINES 256 #define MAX_AUTOJOIN 16 #define PING_INTERVAL 90 #define CHAN_W 18 #define USER_W 16 #define RECONNECT_DELAY 5 /* colour pair ids */ #define C_BORDER 1 #define C_HEADER 2 #define C_ME 4 #define C_JOIN 5 #define C_MENTION 6 #define C_STATUS 7 #define C_INPUT 8 #define C_CHAN_SEL 9 #define C_CHAN 10 #define C_UNREAD 11 #define C_MENTION_CHAN 12 #define C_URL 13 #define C_ACTION 14 #define C_SERVER_HDR 15 /* server name row in channel list */ #define C_NICK_BASE 16 #define C_NICK_COUNT 8 /* line flags */ #define F_MSG 0 #define F_ME 1 #define F_MENTION 2 #define F_JOIN 3 #define F_STATUS 4 #define F_ACTION 5 /* ── data structures ───────────────────────────────────────────────────────── */ typedef struct { char ts[8]; int flag; char text[MAX_LINE]; } Line; typedef struct { char name[MAX_CHAN]; int srv; /* index of owning server */ Line lines[MAX_HISTORY]; int line_head; int line_count; char users[MAX_USERS][MAX_NICK]; int user_count; int unread; int mention; int scroll; char topic[MAX_LINE]; int names_pending; /* 1 while receiving fresh 353 NAMES reply */ } Channel; typedef struct { char text[MAX_INPUT]; } HistEntry; /* IRC event types — each carries a server index so the UI routes correctly */ typedef enum { EV_CONNECTED, EV_STATUS, EV_ERROR, EV_SERVER_TEXT, EV_PRIVMSG, EV_JOIN, EV_PART, EV_QUIT_MSG, EV_NICK_CHANGE, EV_NAMES, EV_KICK, EV_RAW, EV_RECONNECT, EV_TOPIC, EV_NAMES_END } EvType; typedef struct { EvType type; int srv; /* server index */ char nick[MAX_NICK]; char chan[MAX_CHAN]; char text[MAX_LINE]; char extra[MAX_NICK]; } Event; /* per-server state */ typedef struct { /* config */ char host[MAX_HOST]; int port; char nick[MAX_NICK]; char autojoin[MAX_AUTOJOIN][MAX_CHAN]; int autojoin_count; int use_tls; char sasl_user[MAX_NICK]; char sasl_pass[256]; /* runtime */ int connected; volatile int net_stop; pthread_t net_tid; pthread_mutex_t send_lock; int sock; SSL *ssl; int reconnect_pending; pthread_t reconnect_tid; int sasl_auth_sent; /* guard: only send AUTHENTICATE once per session */ /* per-server event pipe */ int evpipe[2]; } Server; /* ── globals ───────────────────────────────────────────────────────────────── */ static Server g_srv[MAX_SERVERS]; static int g_srv_count = 0; static SSL_CTX *g_ssl_ctx = NULL; /* channels (flat array, each has a .srv index) */ static Channel g_chans[MAX_CHANS_TOTAL]; static int g_chan_count = 0; static int g_active = 0; /* index into g_chans; -1 = no channels */ /* global ignore list */ static char g_ignore[MAX_IGNORE][MAX_NICK]; static int g_ignore_count = 0; /* input */ static char g_input[MAX_INPUT]; static int g_input_len = 0; static int g_input_cur = 0; static HistEntry g_hist[MAX_HIST_LINES]; static int g_hist_count = 0; static int g_hist_pos = -1; /* tab completion */ static char g_tab_matches[MAX_USERS][MAX_NICK]; static int g_tab_count = 0; static int g_tab_idx = 0; static int g_tab_word_start = 0; static int g_tab_active = 0; /* single event pipe the UI polls — all servers write here */ static int g_evpipe[2]; /* curses windows */ static WINDOW *win_chan = NULL; static WINDOW *win_chat = NULL; static WINDOW *win_users = NULL; static WINDOW *win_input = NULL; static WINDOW *win_status = NULL; /* ── utility ───────────────────────────────────────────────────────────────── */ static void str_upper(char *dst, const char *src, int n) { int i; for (i = 0; i < n-1 && src[i]; i++) dst[i] = toupper((unsigned char)src[i]); dst[i] = '\0'; } static int str_icase_starts(const char *hay, const char *needle) { while (*needle) { if (tolower((unsigned char)*hay) != tolower((unsigned char)*needle)) return 0; hay++; needle++; } return 1; } static int nick_colour(const char *nick) { unsigned int h = 0; for (; *nick; nick++) h = (h * 31 + (unsigned char)*nick) & 0xFFFF; return C_NICK_BASE + (h % C_NICK_COUNT); } static void timestamp(char *buf, int n) { time_t t = time(NULL); strftime(buf, n, "%H:%M", localtime(&t)); } /* ── channel management ────────────────────────────────────────────────────── */ static int chan_find(int srv, const char *name) { for (int i = 0; i < g_chan_count; i++) if (g_chans[i].srv == srv && strcasecmp(g_chans[i].name, name) == 0) return i; return -1; } static int chan_add(int srv, const char *name) { int i = chan_find(srv, name); if (i >= 0) return i; if (g_chan_count >= MAX_CHANS_TOTAL) return 0; i = g_chan_count++; memset(&g_chans[i], 0, sizeof(Channel)); strncpy(g_chans[i].name, name, MAX_CHAN-1); g_chans[i].srv = srv; return i; } static void chan_remove(int idx) { if (idx < 0 || idx >= g_chan_count) return; for (int i = idx; i < g_chan_count-1; i++) g_chans[i] = g_chans[i+1]; g_chan_count--; if (g_active >= g_chan_count) g_active = g_chan_count - 1; if (g_active < 0) g_active = 0; } static Line *chan_line(Channel *ch, int idx) { if (idx < 0 || idx >= ch->line_count) return NULL; int real = (ch->line_head - ch->line_count + idx + MAX_HISTORY) % MAX_HISTORY; return &ch->lines[real]; } /* strip IRC formatting: \x02 bold, \x03 colour, \x0f reset, \x1d italic, \x1f underline, \x1e strikethrough, \x11 monospace */ static void strip_irc_fmt(const char *in, char *out, int outlen) { int j = 0; for (int i = 0; in[i] && j < outlen-1; i++) { unsigned char c = (unsigned char)in[i]; if (c==0x02||c==0x0f||c==0x1d||c==0x1f||c==0x1e||c==0x11) continue; if (c == 0x03) { /* colour: \x03[fg[,bg]] — skip digits */ i++; if (in[i] && isdigit((unsigned char)in[i])) i++; if (in[i] && isdigit((unsigned char)in[i])) i++; if (in[i] == ',') { i++; if (in[i] && isdigit((unsigned char)in[i])) i++; if (in[i] && isdigit((unsigned char)in[i])) i++; } i--; continue; } out[j++] = (char)c; } out[j] = '\0'; } static void chan_addline(Channel *ch, int flag, const char *text) { char ts[8]; timestamp(ts, sizeof(ts)); Line *l = &ch->lines[ch->line_head % MAX_HISTORY]; strncpy(l->ts, ts, sizeof(l->ts)-1); strip_irc_fmt(text, l->text, MAX_LINE); l->flag = flag; ch->line_head = (ch->line_head + 1) % MAX_HISTORY; if (ch->line_count < MAX_HISTORY) ch->line_count++; if (flag != F_STATUS) ch->unread++; } /* get the status channel for a server (create if needed) */ static int srv_status_chan(int srv) { return chan_add(srv, "*status*"); } static void srv_status_msg(int srv, const char *msg) { int ci = srv_status_chan(srv); chan_addline(&g_chans[ci], F_STATUS, msg); } static void srv_status_fmt(int srv, const char *fmt, ...) { char buf[MAX_LINE]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); srv_status_msg(srv, buf); } /* Store user as "" where prefix is @, +, ~, &, % or space. Lookup is always by bare nick (skip leading mode char). */ static int user_find(Channel *ch, const char *nick) { for (int i = 0; i < ch->user_count; i++) { const char *stored = ch->users[i]; /* skip stored mode prefix */ if (*stored == '@' || *stored == '+' || *stored == '~' || *stored == '&' || *stored == '%' || *stored == ' ') stored++; if (strcasecmp(stored, nick) == 0) return i; } return -1; } static void chan_adduser(Channel *ch, char mode, const char *nick) { int idx = user_find(ch, nick); char entry[MAX_NICK]; entry[0] = mode ? mode : ' '; strncpy(entry+1, nick, MAX_NICK-2); if (idx >= 0) { /* update mode in place */ strncpy(ch->users[idx], entry, MAX_NICK-1); return; } if (ch->user_count >= MAX_USERS) return; strncpy(ch->users[ch->user_count++], entry, MAX_NICK-1); } static void chan_removeuser(Channel *ch, const char *nick) { int idx = user_find(ch, nick); if (idx < 0) return; for (int j = idx; j < ch->user_count-1; j++) memcpy(ch->users[j], ch->users[j+1], MAX_NICK); ch->user_count--; } static int mode_rank(char c) { /* lower = higher privilege */ switch (c) { case '~': return 0; /* founder/owner */ case '&': return 1; /* protected */ case '@': return 2; /* op */ case '%': return 3; /* halfop */ case '+': return 4; /* voice */ default: return 5; /* regular */ } } static int user_cmp(const void *a, const void *b) { const char *sa = (const char *)a; const char *sb = (const char *)b; int ra = mode_rank(*sa); int rb = mode_rank(*sb); if (ra != rb) return ra - rb; /* same rank: sort by nick (skip mode prefix) */ const char *na = (*sa == ' ' || mode_rank(*sa) < 5) ? sa+1 : sa; const char *nb = (*sb == ' ' || mode_rank(*sb) < 5) ? sb+1 : sb; return strcasecmp(na, nb); } static void chan_sort_users(Channel *ch) { qsort(ch->users, ch->user_count, MAX_NICK, user_cmp); } /* next/prev channel index that is NOT a *status* channel (unless it's the only kind), skipping nothing — just a linear wrap */ /* Build the visual order of channels (same traversal as draw_channels). Returns the count; fills idx[] with g_chans indices in display order. */ static int visual_order(int *idx, int max) { int n = 0; for (int si = 0; si < g_srv_count && n < max; si++) { /* *status* first */ for (int ci = 0; ci < g_chan_count && n < max; ci++) { if (g_chans[ci].srv != si) continue; if (strcmp(g_chans[ci].name, "*status*") == 0) idx[n++] = ci; } /* then regular channels */ for (int ci = 0; ci < g_chan_count && n < max; ci++) { if (g_chans[ci].srv != si) continue; if (strcmp(g_chans[ci].name, "*status*") != 0) idx[n++] = ci; } } return n; } static int chan_next(int cur, int dir) { if (g_chan_count <= 1) return cur; int idx[MAX_CHANS_TOTAL]; int n = visual_order(idx, MAX_CHANS_TOTAL); if (n == 0) return cur; /* find cur in visual order */ int pos = 0; for (int i = 0; i < n; i++) if (idx[i] == cur) { pos = i; break; } pos = (pos + dir + n) % n; return idx[pos]; } /* ── ignore list ───────────────────────────────────────────────────────────── */ static int is_ignored(const char *nick) { for (int i = 0; i < g_ignore_count; i++) if (strcasecmp(g_ignore[i], nick) == 0) return 1; return 0; } static void ignore_add(const char *nick) { if (is_ignored(nick) || g_ignore_count >= MAX_IGNORE) return; strncpy(g_ignore[g_ignore_count++], nick, MAX_NICK-1); } static void ignore_remove(const char *nick) { for (int i = 0; i < g_ignore_count; i++) { if (strcasecmp(g_ignore[i], nick) == 0) { for (int j = i; j < g_ignore_count-1; j++) memcpy(g_ignore[j], g_ignore[j+1], MAX_NICK); g_ignore_count--; return; } } } /* ── event pipe ────────────────────────────────────────────────────────────── */ static void ev_push(const Event *ev) { write(g_evpipe[1], ev, sizeof(Event)); } static void ev_simple(EvType type, int srv, const char *nick, const char *chan, const char *text) { Event ev; memset(&ev, 0, sizeof(ev)); ev.type = type; ev.srv = srv; if (nick) strncpy(ev.nick, nick, MAX_NICK-1); if (chan) strncpy(ev.chan, chan, MAX_CHAN-1); if (text) strncpy(ev.text, text, MAX_LINE-1); ev_push(&ev); } /* ── network send ──────────────────────────────────────────────────────────── */ static int srv_send_raw(int si, const char *line) { char buf[MAX_LINE]; int n = snprintf(buf, sizeof(buf), "%s\r\n", line); if (n <= 0 || n >= (int)sizeof(buf)) return -1; Server *s = &g_srv[si]; pthread_mutex_lock(&s->send_lock); int r; if (s->use_tls && s->ssl) r = SSL_write(s->ssl, buf, n); else r = send(s->sock, buf, n, 0); pthread_mutex_unlock(&s->send_lock); return r > 0 ? 0 : -1; } static void srv_sendf(int si, const char *fmt, ...) { char buf[MAX_LINE]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf), fmt, ap); va_end(ap); srv_send_raw(si, buf); } /* ── IRC parser ────────────────────────────────────────────────────────────── */ typedef struct { char prefix[MAX_LINE]; char cmd[32]; char params[16][MAX_LINE]; int nparams; char trail[MAX_LINE]; } IrcMsg; static void nick_from_prefix(const char *prefix, char *nick, int n) { const char *bang = strchr(prefix, '!'); if (bang) { int len = (int)(bang - prefix); if (len >= n) len = n-1; memcpy(nick, prefix, len); nick[len] = '\0'; } else { strncpy(nick, prefix, n-1); nick[n-1] = '\0'; } } static void parse_irc(const char *raw, IrcMsg *m) { memset(m, 0, sizeof(*m)); const char *p = raw; if (*p == ':') { p++; const char *end = strchr(p, ' '); if (!end) return; int len = (int)(end-p); if (len>=(int)sizeof(m->prefix)) len=sizeof(m->prefix)-1; memcpy(m->prefix, p, len); p = end+1; } { const char *end = strchr(p, ' '); int len = end ? (int)(end-p) : (int)strlen(p); if (len>=(int)sizeof(m->cmd)) len=sizeof(m->cmd)-1; memcpy(m->cmd, p, len); str_upper(m->cmd, m->cmd, sizeof(m->cmd)); if (!end) return; p = end+1; } while (*p) { if (*p == ':') { strncpy(m->trail, p+1, MAX_LINE-1); break; } const char *end = strchr(p, ' '); int len = end ? (int)(end-p) : (int)strlen(p); if (m->nparams < 16) { if (len>=MAX_LINE) len=MAX_LINE-1; memcpy(m->params[m->nparams], p, len); m->nparams++; } if (!end) break; p = end+1; } } /* ── base64 for SASL ───────────────────────────────────────────────────────── */ static const char b64tab[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; static void base64_encode(const unsigned char *in, int inlen, char *out) { int j = 0; for (int i = 0; i < inlen; i += 3) { unsigned int a = (unsigned char)in[i]; unsigned int b = (i+1 < inlen) ? (unsigned char)in[i+1] : 0; unsigned int c = (i+2 < inlen) ? (unsigned char)in[i+2] : 0; unsigned int n = (a << 16) | (b << 8) | c; out[j++] = b64tab[(n >> 18) & 63]; out[j++] = b64tab[(n >> 12) & 63]; out[j++] = (i+1 < inlen) ? b64tab[(n >> 6) & 63] : '='; out[j++] = (i+2 < inlen) ? b64tab[n & 63] : '='; } out[j] = '\0'; } /* ── IRC line handler (net thread — uses si to tag every event) ────────────── */ static void handle_irc_line(int si, const char *raw) { IrcMsg m; parse_irc(raw, &m); char nick[MAX_NICK]=""; nick_from_prefix(m.prefix, nick, sizeof(nick)); Server *s = &g_srv[si]; if (strcmp(m.cmd,"PING")==0) { srv_sendf(si, "PONG :%s", m.trail[0]?m.trail:m.params[0]); return; } /* SASL / CAP negotiation */ if (strcmp(m.cmd,"CAP")==0) { char sub[16]=""; if (m.nparams>=2) str_upper(sub, m.params[1], sizeof(sub)); else if (m.nparams>=1) str_upper(sub, m.params[0], sizeof(sub)); /* log all CAP lines to status for visibility */ char capdbg[MAX_LINE]; snprintf(capdbg,sizeof(capdbg),"[CAP] %s",raw); ev_simple(EV_STATUS, si, NULL, NULL, capdbg); if (strcmp(sub,"LS")==0) { /* server advertising capabilities -- request sasl */ srv_send_raw(si, "CAP REQ :sasl"); } else if (strcmp(sub,"ACK")==0) { const char *caps = m.trail[0] ? m.trail : (m.nparams>0?m.params[m.nparams-1]:""); if (strstr(caps,"sasl")) { if (!s->sasl_auth_sent) { s->sasl_auth_sent = 1; srv_send_raw(si, "AUTHENTICATE PLAIN"); } } else { srv_send_raw(si, "CAP END"); } } else if (strcmp(sub,"NAK")==0) { ev_simple(EV_ERROR, si, NULL, NULL, "Server rejected SASL CAP -- connecting without SASL."); srv_send_raw(si, "CAP END"); srv_sendf(si, "NICK %s", s->nick); srv_sendf(si, "USER %s 0 * :sirc", s->nick); } return; } if (strcmp(m.cmd,"AUTHENTICATE")==0) { /* server sends AUTHENTICATE + (trail or param) to prompt us */ const char *challenge = m.trail[0] ? m.trail : (m.nparams>0 ? m.params[0] : ""); if (strcmp(challenge,"+")==0) { /* build PLAIN payload: authzid\0authcid\0passwd must use memcpy — sasl_user/pass may not contain \0 but we need to embed \0 separators manually */ char payload[512]; int ulen = strlen(s->sasl_user); int plen_pass = strlen(s->sasl_pass); /* layout: [sasl_user]\0[sasl_user]\0[sasl_pass] */ int total = ulen + 1 + ulen + 1 + plen_pass; if (total < (int)sizeof(payload)) { int pos = 0; memcpy(payload + pos, s->sasl_user, ulen); pos += ulen; payload[pos++] = '\0'; memcpy(payload + pos, s->sasl_user, ulen); pos += ulen; payload[pos++] = '\0'; memcpy(payload + pos, s->sasl_pass, plen_pass); pos += plen_pass; char enc[700]; base64_encode((unsigned char*)payload, total, enc); srv_sendf(si, "AUTHENTICATE %s", enc); } } return; } if (strcmp(m.cmd,"903")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "SASL authentication successful."); srv_send_raw(si, "CAP END"); srv_sendf(si, "NICK %s", s->nick); srv_sendf(si, "USER %s 0 * :sirc", s->nick); return; } if (strcmp(m.cmd,"902")==0||strcmp(m.cmd,"904")==0|| strcmp(m.cmd,"905")==0||strcmp(m.cmd,"906")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"SASL failed (%s): %s",m.cmd,m.trail); ev_simple(EV_ERROR, si, NULL, NULL, buf); srv_send_raw(si, "CAP END"); srv_sendf(si, "NICK %s", s->nick); srv_sendf(si, "USER %s 0 * :sirc", s->nick); return; } /* 001 */ if (strcmp(m.cmd,"001")==0) { s->connected=1; ev_simple(EV_CONNECTED, si, NULL, NULL, m.trail); ev_simple(EV_SERVER_TEXT, si, NULL, NULL, m.trail); return; } /* PRIVMSG */ if (strcmp(m.cmd,"PRIVMSG")==0) { Event ev; memset(&ev,0,sizeof(ev)); ev.type=EV_PRIVMSG; ev.srv=si; strncpy(ev.nick, nick, MAX_NICK-1); strncpy(ev.chan, m.nparams>0?m.params[0]:"", MAX_CHAN-1); strncpy(ev.text, m.trail, MAX_LINE-1); ev_push(&ev); return; } /* JOIN */ if (strcmp(m.cmd,"JOIN")==0) { const char *ch=m.trail[0]?m.trail:(m.nparams>0?m.params[0]:""); ev_simple(EV_JOIN, si, nick, ch, NULL); return; } /* PART */ if (strcmp(m.cmd,"PART")==0) { ev_simple(EV_PART, si, nick, m.nparams>0?m.params[0]:"", m.trail); return; } /* QUIT */ if (strcmp(m.cmd,"QUIT")==0) { ev_simple(EV_QUIT_MSG, si, nick, NULL, m.trail); return; } /* NICK */ if (strcmp(m.cmd,"NICK")==0) { Event ev; memset(&ev,0,sizeof(ev)); ev.type=EV_NICK_CHANGE; ev.srv=si; strncpy(ev.nick, nick, MAX_NICK-1); const char *nn=m.trail[0]?m.trail:(m.nparams>0?m.params[0]:""); strncpy(ev.extra, nn, MAX_NICK-1); ev_push(&ev); return; } /* 353 NAMES */ if (strcmp(m.cmd,"353")==0) { const char *ch=m.nparams>=3?m.params[2]:""; char tmp[MAX_LINE]; strncpy(tmp,m.trail,MAX_LINE-1); char *tok=strtok(tmp," "); while (tok) { Event ev; memset(&ev,0,sizeof(ev)); ev.type=EV_NAMES; ev.srv=si; strncpy(ev.chan, ch, MAX_CHAN-1); /* ev.extra[0] = mode prefix char (or 0 if none) */ const char *n=tok; if (*n=='@'||*n=='+'||*n=='~'||*n=='&'||*n=='%') { ev.extra[0]=*n; n++; } strncpy(ev.nick, n, MAX_NICK-1); ev_push(&ev); tok=strtok(NULL," "); } return; } /* KICK */ if (strcmp(m.cmd,"KICK")==0) { Event ev; memset(&ev,0,sizeof(ev)); ev.type=EV_KICK; ev.srv=si; strncpy(ev.nick, nick, MAX_NICK-1); strncpy(ev.chan, m.nparams>0?m.params[0]:"", MAX_CHAN-1); strncpy(ev.extra, m.nparams>1?m.params[1]:"", MAX_NICK-1); strncpy(ev.text, m.trail, MAX_LINE-1); ev_push(&ev); return; } /* 433 nick in use */ if (strcmp(m.cmd,"433")==0) { ev_simple(EV_ERROR, si, NULL, NULL, "Nickname already in use."); return; } /* server text */ if (strcmp(m.cmd,"002")==0||strcmp(m.cmd,"003")==0||strcmp(m.cmd,"004")==0|| strcmp(m.cmd,"375")==0||strcmp(m.cmd,"372")==0||strcmp(m.cmd,"376")==0) { ev_simple(EV_SERVER_TEXT, si, NULL, NULL, m.trail[0]?m.trail:m.params[m.nparams>1?1:0]); return; } /* MODE */ if (strcmp(m.cmd,"MODE")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"MODE %s %s",m.params[0],m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } /* TOPIC */ if (strcmp(m.cmd,"331")==0) { const char *ch=m.nparams>1?m.params[1]:m.params[0]; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] No topic set",ch); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"332")==0) { const char *ch=m.nparams>1?m.params[1]:m.params[0]; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] Topic: %s",ch,m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); ev_simple(EV_TOPIC, si, NULL, ch, m.trail); return; } if (strcmp(m.cmd,"333")==0) { const char *ch=m.nparams>1?m.params[1]:""; const char *setter=m.nparams>2?m.params[2]:""; char sn[MAX_NICK]; strncpy(sn,setter,MAX_NICK-1); char *bang=strchr(sn,'!'); if(bang)*bang='\0'; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] Topic set by %s",ch,sn); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"TOPIC")==0) { const char *ch=m.nparams>0?m.params[0]:""; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[%s] %s changed topic to: %s",ch,nick,m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); ev_simple(EV_SERVER_TEXT, si, NULL, ch, buf); ev_simple(EV_TOPIC, si, NULL, ch, m.trail); return; } /* WHOIS */ if (strcmp(m.cmd,"311")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] %s (%s@%s): %s", m.nparams>1?m.params[1]:"",m.nparams>2?m.params[2]:"", m.nparams>3?m.params[3]:"",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"312")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] server: %s (%s)", m.nparams>2?m.params[2]:"",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"313")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] %s is an IRC operator", m.nparams>1?m.params[1]:""); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"317")==0) { int idle=m.nparams>2?atoi(m.params[2]):0; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] idle: %dm%ds",idle/60,idle%60); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"318")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "[whois] end"); return; } if (strcmp(m.cmd,"319")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] channels: %s",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"307")==0||strcmp(m.cmd,"330")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[whois] %s",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } /* WHO */ if (strcmp(m.cmd,"352")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[who] %-16s %s!%s@%s", m.nparams>1?m.params[1]:"*", m.nparams>6?m.params[6]:"", m.nparams>2?m.params[2]:"", m.nparams>3?m.params[3]:""); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"315")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "[who] end"); return; } /* LIST */ if (strcmp(m.cmd,"321")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "[list] Channel Users Topic"); return; } if (strcmp(m.cmd,"322")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[list] %-20s %-6s %s", m.nparams>1?m.params[1]:"", m.nparams>2?m.params[2]:"",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"323")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "[list] end"); return; } if (strcmp(m.cmd,"366")==0) { /* end of NAMES — reset pending flag so next /names clears again */ const char *ch = m.nparams>=2 ? m.params[1] : (m.nparams>=1 ? m.params[0] : ""); ev_simple(EV_NAMES_END, si, NULL, ch, NULL); return; } /* AWAY */ if (strcmp(m.cmd,"301")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"[away] %s is away: %s", m.nparams>1?m.params[1]:"",m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"305")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "You are no longer marked as away."); return; } if (strcmp(m.cmd,"306")==0) { ev_simple(EV_STATUS, si, NULL, NULL, "You have been marked as away."); return; } /* INVITE */ if (strcmp(m.cmd,"INVITE")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"** %s invites you to %s",nick, m.trail[0]?m.trail:m.params[m.nparams>1?1:0]); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } if (strcmp(m.cmd,"341")==0) { char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"Invited %s to %s", m.nparams>1?m.params[1]:"",m.nparams>2?m.params[2]:m.trail); ev_simple(EV_STATUS, si, NULL, NULL, buf); return; } /* NOTICE */ if (strcmp(m.cmd,"NOTICE")==0) { const char *target=m.nparams>0?m.params[0]:""; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"--%s-- %s",nick[0]?nick:"server",m.trail); if (target[0]=='#') { Event ev2; memset(&ev2,0,sizeof(ev2)); ev2.type=EV_SERVER_TEXT; ev2.srv=si; strncpy(ev2.chan, target, MAX_CHAN-1); strncpy(ev2.text, buf, MAX_LINE-1); ev_push(&ev2); } else { ev_simple(EV_STATUS, si, NULL, NULL, buf); } return; } ev_simple(EV_RAW, si, NULL, NULL, raw); } /* ── network thread ────────────────────────────────────────────────────────── */ static void srv_close(int si) { Server *s=&g_srv[si]; if (s->use_tls && s->ssl) { SSL_shutdown(s->ssl); SSL_free(s->ssl); s->ssl=NULL; } if (s->sock>=0) { close(s->sock); s->sock=-1; } s->connected=0; } static int srv_connect(int si) { Server *s=&g_srv[si]; char portstr[8]; snprintf(portstr,sizeof(portstr),"%d",s->port); struct addrinfo hints,*res,*r; memset(&hints,0,sizeof(hints)); hints.ai_family=AF_UNSPEC; hints.ai_socktype=SOCK_STREAM; if (getaddrinfo(s->host,portstr,&hints,&res)!=0) return -1; s->sock=-1; for (r=res; r; r=r->ai_next) { int fd=socket(r->ai_family,r->ai_socktype,r->ai_protocol); if (fd<0) continue; int flags=fcntl(fd,F_GETFL,0); fcntl(fd,F_SETFL,flags|O_NONBLOCK); int rc=connect(fd,r->ai_addr,r->ai_addrlen); if (rc==0||errno==EINPROGRESS) { fd_set wfds; FD_ZERO(&wfds); FD_SET(fd,&wfds); struct timeval tv={15,0}; if (select(fd+1,NULL,&wfds,NULL,&tv)>0) { int err=0; socklen_t el=sizeof(err); getsockopt(fd,SOL_SOCKET,SO_ERROR,&err,&el); if (err==0) { s->sock=fd; break; } } } close(fd); } freeaddrinfo(res); if (s->sock<0) return -1; int flags=fcntl(s->sock,F_GETFL,0); fcntl(s->sock,F_SETFL,flags&~O_NONBLOCK); if (s->use_tls) { if (!g_ssl_ctx) { SSL_library_init(); SSL_load_error_strings(); g_ssl_ctx=SSL_CTX_new(TLS_client_method()); if (!g_ssl_ctx) return -1; SSL_CTX_set_verify(g_ssl_ctx,SSL_VERIFY_PEER,NULL); SSL_CTX_set_default_verify_paths(g_ssl_ctx); } s->ssl=SSL_new(g_ssl_ctx); SSL_set_fd(s->ssl,s->sock); SSL_set_tlsext_host_name(s->ssl,s->host); if (SSL_connect(s->ssl)<=0) { SSL_free(s->ssl); s->ssl=NULL; return -1; } } return 0; } typedef struct { int si; } NetArg; static void *net_thread(void *arg) { NetArg *na=(NetArg*)arg; int si=na->si; free(na); Server *s=&g_srv[si]; char buf[MAX_LINE]; snprintf(buf,sizeof(buf),"Connecting to %s:%d%s...", s->host,s->port,s->use_tls?" (TLS)":""); ev_simple(EV_STATUS, si, NULL, NULL, buf); if (srv_connect(si)<0) { ev_simple(EV_ERROR, si, NULL, NULL, "Connection failed."); ev_simple(EV_RECONNECT, si, NULL, NULL, NULL); return NULL; } int sasl_pending=(s->sasl_user[0]&&s->sasl_pass[0])?1:0; s->sasl_auth_sent = 0; if (sasl_pending) { /* CAP LS 302: proper negotiation, handler sends CAP REQ :sasl on LS reply */ srv_send_raw(si,"CAP LS 302"); } else { srv_sendf(si,"NICK %s",s->nick); srv_sendf(si,"USER %s 0 * :sirc",s->nick); } char readbuf[8192], linebuf[MAX_LINE*4]; int linelen=0; time_t last_ping=time(NULL); while (!s->net_stop) { if (time(NULL)-last_ping>PING_INTERVAL) { srv_sendf(si,"PING :%s",s->host); last_ping=time(NULL); } fd_set rfds; FD_ZERO(&rfds); FD_SET(s->sock,&rfds); struct timeval tv={5,0}; int sel=select(s->sock+1,&rfds,NULL,NULL,&tv); if (sel==0) continue; if (sel<0) { ev_simple(EV_ERROR,si,NULL,NULL,"Select error."); break; } int n; if (s->use_tls && s->ssl) n=SSL_read(s->ssl,readbuf,sizeof(readbuf)-1); else n=recv(s->sock,readbuf,sizeof(readbuf)-1,0); if (n<=0) { ev_simple(EV_ERROR,si,NULL,NULL,"Server closed connection."); break; } readbuf[n]='\0'; for (int i=0; i0 && linebuf[linelen-1]=='\r') linelen--; linebuf[linelen]='\0'; if (linelen>0) handle_irc_line(si,linebuf); linelen=0; } else if (linelen<(int)sizeof(linebuf)-1) { linebuf[linelen++]=c; } } last_ping=time(NULL); } srv_close(si); ev_simple(EV_RECONNECT, si, NULL, NULL, NULL); return NULL; } /* ── reconnect ─────────────────────────────────────────────────────────────── */ static void start_net_thread(int si); typedef struct { int si; } ReconArg; static void *reconnect_thread(void *arg) { ReconArg *ra=(ReconArg*)arg; int si=ra->si; free(ra); sleep(RECONNECT_DELAY); g_srv[si].reconnect_pending=0; start_net_thread(si); return NULL; } static void start_net_thread(int si) { g_srv[si].net_stop=0; NetArg *na=malloc(sizeof(NetArg)); na->si=si; pthread_create(&g_srv[si].net_tid, NULL, net_thread, na); } static void schedule_reconnect(int si) { if (g_srv[si].reconnect_pending) return; g_srv[si].reconnect_pending=1; srv_status_fmt(si,"Reconnecting in %ds...",RECONNECT_DELAY); ReconArg *ra=malloc(sizeof(ReconArg)); ra->si=si; pthread_create(&g_srv[si].reconnect_tid, NULL, reconnect_thread, ra); pthread_detach(g_srv[si].reconnect_tid); } /* ── config file ───────────────────────────────────────────────────────────── */ static int cfg_bool(const char *v) { return strcasecmp(v,"true")==0||strcasecmp(v,"yes")==0|| strcasecmp(v,"1")==0||strcasecmp(v,"on")==0; } /* add a server entry with defaults; returns its index */ static int srv_alloc(void) { if (g_srv_count>=MAX_SERVERS) return -1; int si=g_srv_count++; Server *s=&g_srv[si]; memset(s,0,sizeof(Server)); strncpy(s->host,"irc.libera.chat",MAX_HOST-1); s->port=6697; strncpy(s->nick,"circ_user",MAX_NICK-1); s->use_tls=1; s->sock=-1; pthread_mutex_init(&s->send_lock,NULL); return si; } static void srv_add_autojoin(int si, const char *chanlist) { char tmp[MAX_LINE]; strncpy(tmp, chanlist, MAX_LINE-1); char *tok = strtok(tmp, ","); while (tok) { /* trim leading and trailing spaces */ while (*tok == ' ') tok++; char *end = tok + strlen(tok) - 1; while (end > tok && *end == ' ') *end-- = '\0'; if (*tok && g_srv[si].autojoin_count < MAX_AUTOJOIN) { char ch[MAX_CHAN]; strncpy(ch, tok, MAX_CHAN-1); if (ch[0] != '#') { memmove(ch+1, ch, strlen(ch)+1); ch[0]='#'; } /* avoid duplicates */ int dup = 0; for (int i = 0; i < g_srv[si].autojoin_count; i++) if (strcasecmp(g_srv[si].autojoin[i], ch) == 0) { dup=1; break; } if (!dup) strncpy(g_srv[si].autojoin[g_srv[si].autojoin_count++], ch, MAX_CHAN-1); } tok = strtok(NULL, ","); } } static void load_config(const char *path) { char candidates[2][MAX_HOST]; const char *home=getenv("HOME"); if (home) { snprintf(candidates[0],MAX_HOST,"%s/.sirc",home); snprintf(candidates[1],MAX_HOST,"%s/.config/sirc/config",home); } FILE *f=NULL; if (path) f=fopen(path,"r"); else for (int i=0;i<2&&!f;i++) f=fopen(candidates[i],"r"); if (!f) return; /* defaults that apply before any [server] block */ char def_nick[MAX_NICK]="circ_user"; /* current server being parsed; -1 = not inside a [server] block */ int cur_si=-1; char line[512]; while (fgets(line,sizeof(line),f)) { line[strcspn(line,"\r\n")]='\0'; char *p=line; while (*p==' '||*p=='\t') p++; if (!*p||*p=='#') continue; /* [server] section header */ if (strcmp(p,"[server]")==0) { cur_si=srv_alloc(); if (cur_si>=0) strncpy(g_srv[cur_si].nick,def_nick,MAX_NICK-1); continue; } char *eq=strchr(p,'='); if (!eq) continue; *eq='\0'; char *key=p, *val=eq+1; char *ke=key+strlen(key)-1; while (ke>key&&(*ke==' '||*ke=='\t')) *ke--='\0'; while (*val==' '||*val=='\t') val++; char *ve=val+strlen(val)-1; while (ve>val&&(*ve==' '||*ve=='\t')) *ve--='\0'; if (cur_si<0) { /* global defaults */ if (strcmp(key,"nick")==0) strncpy(def_nick,val,MAX_NICK-1); else if (strcmp(key,"ignore")==0) { char tmp[512]; strncpy(tmp,val,511); char *tok=strtok(tmp,","); while (tok) { while(*tok==' ')tok++; if(*tok)ignore_add(tok); tok=strtok(NULL,","); } } } else { Server *s=&g_srv[cur_si]; if (strcmp(key,"host")==0) strncpy(s->host,val,MAX_HOST-1); else if (strcmp(key,"port")==0) s->port=atoi(val); else if (strcmp(key,"nick")==0) strncpy(s->nick,val,MAX_NICK-1); else if (strcmp(key,"tls")==0) s->use_tls=cfg_bool(val); else if (strcmp(key,"sasl_user")==0) strncpy(s->sasl_user,val,MAX_NICK-1); else if (strcmp(key,"sasl_pass")==0) strncpy(s->sasl_pass,val,255); else if (strcmp(key,"channel")==0) srv_add_autojoin(cur_si,val); } } fclose(f); } /* ── URL detection ─────────────────────────────────────────────────────────── */ static int find_url(const char *str, int start, int *len) { for (int i=start; str[i]; i++) { if (strncmp(str+i,"http://",7)==0||strncmp(str+i,"https://",8)==0|| strncmp(str+i,"www.",4)==0) { int j=i; while (str[j]&&str[j]!=' '&&str[j]!='\t'&& str[j]!='"'&&str[j]!='\''&&str[j]!='<') j++; *len=j-i; return i; } } return -1; } /* ── draw routines ─────────────────────────────────────────────────────────── */ static void draw_status_bar(void) { int H,W; getmaxyx(stdscr,H,W); (void)H; werase(win_status); wbkgd(win_status,COLOR_PAIR(C_HEADER)); Channel *ch=&g_chans[g_active]; /* topic starts after the channel panel, truncated on the right */ int avail = W - CHAN_W - 1; if (avail < 1) avail = 1; char row[512]; if (ch->topic[0]) snprintf(row, sizeof(row), " %.*s", avail-1, ch->topic); else row[0]='\0'; int l=strlen(row); while (l < avail) row[l++]=' '; row[avail]='\0'; mvwaddnstr(win_status, 0, CHAN_W, row, avail); wnoutrefresh(win_status); } static void draw_channels(void) { int H,W; getmaxyx(win_chan,H,W); (void)W; werase(win_chan); wattron(win_chan,COLOR_PAIR(C_BORDER)|A_BOLD); mvwaddnstr(win_chan,0,0," CHANNELS",CHAN_W-1); wattroff(win_chan,COLOR_PAIR(C_BORDER)|A_BOLD); int row=2; for (int si=0; simention) { attr=COLOR_PAIR(C_MENTION_CHAN)|A_BOLD; pfx="! "; } else if (ch->unread) { attr=COLOR_PAIR(C_UNREAD); pfx="+ "; } else { attr=COLOR_PAIR(C_CHAN); pfx=" "; } char label[CHAN_W+3]; snprintf(label,sizeof(label)," %s%.*s",pfx,CHAN_W-4,ch->name); int ll=strlen(label); while (llline_count==0) { wnoutrefresh(win_chat); return; } typedef struct { int flag; char text[MAX_LINE]; int has_url; } FlatLine; static FlatLine flat[MAX_HISTORY*4]; int flat_count=0; for (int li=0; liline_count && flat_count<(int)(sizeof(flat)/sizeof(flat[0])); li++) { Line *ln=chan_line(ch,li); if (!ln) continue; char full[MAX_LINE]; snprintf(full,sizeof(full),"%s %s",ln->ts,ln->text); int plen=strlen(ln->ts)+1; int avail=chat_w-plen; if(avail<4)avail=4; const char *p=full; int is_first=1; while (*p && flat_count<(int)(sizeof(flat)/sizeof(flat[0]))) { int w=is_first?chat_w:avail; FlatLine *fl=&flat[flat_count++]; fl->flag=ln->flag; fl->has_url=(find_url(ln->text,0,&(int){0})>=0); if ((int)strlen(p)<=w) { if (!is_first) { memset(fl->text,' ',plen); strncpy(fl->text+plen,p,MAX_LINE-plen-1); } else strncpy(fl->text,p,MAX_LINE-1); break; } int cut=w; while (cut>0&&p[cut]!=' ') cut--; if (cut==0) cut=w; if (!is_first) { memset(fl->text,' ',plen); memcpy(fl->text+plen,p,cut); fl->text[plen+cut]='\0'; } else { memcpy(fl->text,p,cut); fl->text[cut]='\0'; } is_first=0; p+=cut; while(*p==' ')p++; } } int max_scroll=flat_count>chat_h?flat_count-chat_h:0; if (ch->scroll>max_scroll) ch->scroll=max_scroll; if (ch->scroll<0) ch->scroll=0; int vis_start=flat_count-chat_h-ch->scroll; if (vis_start<0) vis_start=0; int vis_end=vis_start+chat_h; if (vis_end>flat_count) vis_end=flat_count; for (int i=vis_start; iflag==F_MSG) { wattron(win_chat,COLOR_PAIR(C_STATUS)); mvwaddnstr(win_chat,row,0,fl->text,chat_w-1); wattroff(win_chat,COLOR_PAIR(C_STATUS)); const char *lt=strchr(fl->text,'<'); const char *gt=lt?strchr(lt,'>'):NULL; if (lt&>) { int ns=(int)(lt-fl->text), nl=(int)(gt-lt+1); char nn[MAX_NICK]; int nlen=nl-2; if(nlen>=MAX_NICK)nlen=MAX_NICK-1; memcpy(nn,lt+1,nlen); nn[nlen]='\0'; mvwchgat(win_chat,row,ns,nl,A_BOLD,nick_colour(nn),NULL); } } else { int attr; switch(fl->flag) { case F_ME: attr=COLOR_PAIR(C_ME)|A_BOLD; break; case F_MENTION: attr=COLOR_PAIR(C_MENTION)|A_BOLD; break; case F_JOIN: attr=COLOR_PAIR(C_JOIN); break; case F_ACTION: attr=COLOR_PAIR(C_ACTION)|A_ITALIC; break; default: attr=COLOR_PAIR(C_STATUS); break; } wattron(win_chat,attr); mvwaddnstr(win_chat,row,0,fl->text,chat_w-1); wattroff(win_chat,attr); } if (fl->has_url) { int ustart=0,ulen,us; while ((us=find_url(fl->text,ustart,&ulen))>=0) { if (us>=chat_w-1) break; int ue=us+ulen; if(ue>chat_w-2)ue=chat_w-2; mvwchgat(win_chat,row,us,ue-us,A_UNDERLINE,C_URL,NULL); ustart=us+ulen; } } } if (ch->scroll>0) { char tag[16]; snprintf(tag,sizeof(tag),"^%d",ch->scroll); wattron(win_chat,COLOR_PAIR(C_STATUS)|A_DIM); mvwaddstr(win_chat,0,chat_w-(int)strlen(tag)-1,tag); wattroff(win_chat,COLOR_PAIR(C_STATUS)|A_DIM); } wnoutrefresh(win_chat); } static void draw_users(void) { int H,W; getmaxyx(win_users,H,W); (void)W; werase(win_users); Channel *ch=&g_chans[g_active]; char hdr[32]; snprintf(hdr,sizeof(hdr)," USERS (%d)",ch->user_count); wattron(win_users,COLOR_PAIR(C_BORDER)|A_BOLD); mvwaddnstr(win_users,0,1,hdr,USER_W-2); wattroff(win_users,COLOR_PAIR(C_BORDER)|A_BOLD); for (int i=0; iuser_count; i++) { int y=i+2; if(y>=H-1) break; const char *entry = ch->users[i]; char mode = entry[0]; const char *bare = entry+1; /* nick without prefix */ /* colour the mode symbol */ if (mode=='@'||mode=='~'||mode=='&') { wattron(win_users, COLOR_PAIR(C_MENTION)|A_BOLD); mvwaddnstr(win_users,y,1,&mode,1); wattroff(win_users, COLOR_PAIR(C_MENTION)|A_BOLD); } else if (mode=='+'||mode=='%') { wattron(win_users, COLOR_PAIR(C_ME)|A_BOLD); mvwaddnstr(win_users,y,1,&mode,1); wattroff(win_users, COLOR_PAIR(C_ME)|A_BOLD); } else { mvwaddch(win_users,y,1,' '); } /* nick in normal white */ wattron(win_users,COLOR_PAIR(C_CHAN)); mvwaddnstr(win_users,y,2,bare,USER_W-3); wattroff(win_users,COLOR_PAIR(C_CHAN)); } wnoutrefresh(win_users); } static void draw_input(void) { int iw; getmaxyx(win_input,(int){0},iw); werase(win_input); wbkgd(win_input,COLOR_PAIR(C_INPUT)); /* draw "> " prompt */ wattron(win_input, COLOR_PAIR(C_BORDER)|A_BOLD); mvwaddstr(win_input, 0, 0, "> "); wattroff(win_input, COLOR_PAIR(C_BORDER)|A_BOLD); /* available width after prompt */ int pfx = 2; int avail = iw - pfx - 1; if (avail < 1) avail = 1; /* scroll text so cursor stays visible */ int disp = 0, cx = g_input_cur; if (g_input_cur > avail) { disp = g_input_cur - avail; cx = avail; } wattron(win_input, COLOR_PAIR(C_INPUT)); mvwaddnstr(win_input, 0, pfx, g_input + disp, avail); wattroff(win_input, COLOR_PAIR(C_INPUT)); /* place terminal cursor — curs_set(2) gives blinking block on most terms */ wmove(win_input, 0, pfx + cx); wnoutrefresh(win_input); } static void draw_all(void) { draw_status_bar(); draw_channels(); draw_chat(); draw_users(); draw_input(); doupdate(); } /* ── window setup ──────────────────────────────────────────────────────────── */ static void init_colors(void) { start_color(); use_default_colors(); init_pair(C_BORDER, COLOR_CYAN, -1); init_pair(C_HEADER, COLOR_BLACK, COLOR_CYAN); init_pair(C_ME, COLOR_YELLOW, -1); init_pair(C_JOIN, COLOR_BLACK+8, -1); init_pair(C_MENTION, COLOR_RED, -1); init_pair(C_STATUS, COLOR_WHITE, -1); init_pair(C_INPUT, COLOR_WHITE, COLOR_BLACK); init_pair(C_CHAN_SEL, COLOR_BLACK, COLOR_WHITE); init_pair(C_CHAN, COLOR_WHITE, -1); init_pair(C_UNREAD, COLOR_YELLOW, -1); init_pair(C_MENTION_CHAN,COLOR_RED, -1); init_pair(C_URL, COLOR_BLUE, -1); init_pair(C_ACTION, COLOR_MAGENTA, -1); init_pair(C_SERVER_HDR, COLOR_CYAN, -1); static const int nick_cols[C_NICK_COUNT]={ COLOR_GREEN,COLOR_CYAN,COLOR_MAGENTA,COLOR_YELLOW, COLOR_WHITE,COLOR_RED, COLOR_BLUE, COLOR_GREEN }; for (int i=0;isrv; Server *s=&g_srv[si]; switch (ev->type) { case EV_CONNECTED: srv_status_msg(si,"Connected."); do_rejoin_channels(si); break; case EV_STATUS: srv_status_msg(si,ev->text); break; case EV_ERROR: srv_status_fmt(si,"[ERR] %s",ev->text); break; case EV_RECONNECT: if (!s->connected) schedule_reconnect(si); break; case EV_SERVER_TEXT: if (ev->chan[0]) { int ci=chan_find(si,ev->chan); if (ci<0) ci=chan_add(si,ev->chan); chan_addline(&g_chans[ci],F_STATUS,ev->text); if (ci==g_active) g_chans[ci].unread=0; } else { srv_status_msg(si,ev->text); } break; case EV_RAW: srv_status_msg(si,ev->text); break; case EV_PRIVMSG: { if (is_ignored(ev->nick)) break; const char *target=ev->chan, *text=ev->text; const char *dest=(target[0]=='#')?target:ev->nick; int ci=chan_find(si,dest); if(ci<0) ci=chan_add(si,dest); if (text[0]=='\x01'&&strncmp(text+1,"ACTION",6)==0) { const char *act=text+8; char msg[MAX_LINE]; int alen=strlen(act); if(alen>0&&act[alen-1]=='\x01')alen--; snprintf(msg,sizeof(msg),"* %s %.*s",ev->nick,alen,act); int flag=(strcasestr(msg,s->nick)!=NULL)?F_MENTION:F_ACTION; chan_addline(&g_chans[ci],flag,msg); if(ci!=g_active&&flag==F_MENTION) g_chans[ci].mention=1; if(ci==g_active) g_chans[ci].unread=0; break; } char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<%s> %s",ev->nick,text); int flag; if (strcmp(ev->nick,s->nick)==0) flag=F_ME; else if (strcasestr(text,s->nick)!=NULL) flag=F_MENTION; else flag=F_MSG; chan_addline(&g_chans[ci],flag,msg); if(ci!=g_active&&flag==F_MENTION) g_chans[ci].mention=1; if(ci==g_active) g_chans[ci].unread=0; break; } case EV_JOIN: { int ci=chan_find(si,ev->chan); if(ci<0) ci=chan_add(si,ev->chan); if (strcmp(ev->nick,s->nick)==0) { g_chans[ci].user_count=0; g_active=ci; g_chans[ci].unread=0; g_chans[ci].mention=0; srv_status_fmt(si,"Joined %s",ev->chan); } else { char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"-> %s joined",ev->nick); chan_addline(&g_chans[ci],F_JOIN,msg); chan_adduser(&g_chans[ci], 0, ev->nick); } break; } case EV_PART: { int ci=chan_find(si,ev->chan); if(ci<0) break; char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<- %s parted (%s)",ev->nick,ev->text); chan_addline(&g_chans[ci],F_JOIN,msg); if (strcmp(ev->nick,s->nick)==0) chan_remove(ci); else chan_removeuser(&g_chans[ci],ev->nick); break; } case EV_QUIT_MSG: for (int i=0;inick)==0){found=1;break;} if (found) { char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<- %s quit (%s)",ev->nick,ev->text); chan_addline(&g_chans[i],F_JOIN,msg); chan_removeuser(&g_chans[i],ev->nick); } } break; case EV_NICK_CHANGE: if (strcmp(ev->nick,s->nick)==0) { strncpy(s->nick,ev->extra,MAX_NICK-1); srv_status_fmt(si,"You are now known as %s",s->nick); } for (int i=0;inick)==0) { char mode=g_chans[i].users[j][0]; g_chans[i].users[j][0]=mode; strncpy(g_chans[i].users[j]+1,ev->extra,MAX_NICK-2); char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"~ %s is now %s",ev->nick,ev->extra); chan_addline(&g_chans[i],F_JOIN,msg); break; } } } break; case EV_NAMES: { int ci=chan_find(si,ev->chan); if(ci<0) break; /* first nick of a fresh NAMES reply — wipe the stale list */ if (!g_chans[ci].names_pending) { g_chans[ci].user_count = 0; g_chans[ci].names_pending = 1; } chan_adduser(&g_chans[ci], ev->extra[0], ev->nick); chan_sort_users(&g_chans[ci]); break; } case EV_KICK: { int ci=chan_find(si,ev->chan); if(ci<0) break; char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"X %s was kicked by %s (%s)",ev->extra,ev->nick,ev->text); chan_addline(&g_chans[ci],F_JOIN,msg); chan_removeuser(&g_chans[ci],ev->extra); break; } case EV_TOPIC: { int ci=chan_find(si,ev->chan); if(ci<0) break; strip_irc_fmt(ev->text, g_chans[ci].topic, MAX_LINE); break; } case EV_NAMES_END: { int ci=chan_find(si,ev->chan); if(ci<0) break; g_chans[ci].names_pending = 0; break; } } } /* ── tab completion ────────────────────────────────────────────────────────── */ static void tab_reset(void) { g_tab_active=0; g_tab_count=0; g_tab_idx=0; } static void tab_complete(void) { if (g_active<0||g_active>=g_chan_count) return; Channel *ch=&g_chans[g_active]; if (!g_tab_active) { int ws=g_input_cur; while (ws>0&&g_input[ws-1]!=' '&&g_input[ws-1]!='\t') ws--; char prefix[MAX_NICK]; int plen=g_input_cur-ws; if (plen<=0) return; strncpy(prefix,g_input+ws,plen); prefix[plen]='\0'; char *pp=prefix; while(*pp=='@'||*pp=='+'||*pp=='~'||*pp=='&'||*pp=='%') pp++; if (!*pp) return; g_tab_count=0; for (int i=0;iuser_count;i++) { const char *bare=ch->users[i]+1; /* skip mode prefix */ if (str_icase_starts(bare,pp)) strncpy(g_tab_matches[g_tab_count++],bare,MAX_NICK-1); } if (!g_tab_count) return; g_tab_idx=0; g_tab_word_start=ws; g_tab_active=1; } else { g_tab_idx=(g_tab_idx+1)%g_tab_count; } const char *nick=g_tab_matches[g_tab_idx]; char repl[MAX_NICK+3]; snprintf(repl,sizeof(repl),g_tab_word_start==0?"%s: ":"%s ",nick); int rlen=strlen(repl); char newbuf[MAX_INPUT]; int tail=g_input_len-g_input_cur; if(tail<0)tail=0; memcpy(newbuf,g_input,g_tab_word_start); memcpy(newbuf+g_tab_word_start,repl,rlen); memcpy(newbuf+g_tab_word_start+rlen,g_input+g_input_cur,tail); int newlen=g_tab_word_start+rlen+tail; if(newlen>=MAX_INPUT)newlen=MAX_INPUT-1; memcpy(g_input,newbuf,newlen); g_input[newlen]='\0'; g_input_len=newlen; g_input_cur=g_tab_word_start+rlen; } /* ── command processing ────────────────────────────────────────────────────── */ /* current server index = server of the active channel */ static int active_srv(void) { if (g_active>=0&&g_activeconnected) { srv_status_msg(si,"[not connected]"); return; } srv_sendf(si,"PRIVMSG %s :%s",cn,text); char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"<%s> %s",s->nick,text); chan_addline(&g_chans[g_active],F_ME,msg); g_chans[g_active].unread=0; return; } char cmd[64],arg[MAX_INPUT]; cmd[0]='\0'; arg[0]='\0'; const char *sp=strchr(text+1,' '); if (sp) { int cl=(int)(sp-text); if(cl>=(int)sizeof(cmd))cl=sizeof(cmd)-1; memcpy(cmd,text,cl); cmd[cl]='\0'; const char *a=sp+1; while(*a==' ')a++; strncpy(arg,a,MAX_INPUT-1); } else strncpy(cmd,text,sizeof(cmd)-1); for (char *c=cmd;*c;c++) *c=tolower((unsigned char)*c); if (strcmp(cmd,"/join")==0) { char ch[MAX_CHAN]; strncpy(ch,arg[0]?arg:g_srv[si].autojoin_count?g_srv[si].autojoin[0]:"#general",MAX_CHAN-1); if(ch[0]!='#'){memmove(ch+1,ch,strlen(ch)+1);ch[0]='#';} chan_add(si,ch); if(s->connected) srv_sendf(si,"JOIN %s",ch); else srv_status_msg(si,"[not connected]"); } else if (strcmp(cmd,"/part")==0) { const char *ch=arg[0]?arg:g_chans[g_active].name; if(s->connected) srv_sendf(si,"PART %s :bye",ch); else srv_status_msg(si,"[not connected]"); } else if (strcmp(cmd,"/cycle")==0) { const char *ch=arg[0]?arg:g_chans[g_active].name; if(ch[0]!='#'){srv_status_msg(si,"Not in a channel.");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"PART %s :cycling",ch); srv_sendf(si,"JOIN %s",ch); } else if (strcmp(cmd,"/nick")==0) { if(!arg[0]) { srv_status_fmt(si,"Your nick is: %s",s->nick); } else { if(s->connected) srv_sendf(si,"NICK %s",arg); else strncpy(s->nick,arg,MAX_NICK-1); srv_status_fmt(si,"Nick change requested: %s -> %s",s->nick,arg); } } else if (strcmp(cmd,"/msg")==0) { char tgt[MAX_NICK],msg[MAX_LINE]; if(sscanf(arg,"%63s %[^\n]",tgt,msg)==2) { chan_add(si,tgt); if(s->connected) srv_sendf(si,"PRIVMSG %s :%s",tgt,msg); int ci=chan_find(si,tgt); if(ci>=0){ char full[MAX_LINE]; snprintf(full,sizeof(full),"<%s> %s",s->nick,msg); chan_addline(&g_chans[ci],F_ME,full); } } } else if (strcmp(cmd,"/me")==0) { const char *cn=g_chans[g_active].name; if(cn[0]=='*'){srv_status_msg(si,"Not in a channel.");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"PRIVMSG %s :\x01""ACTION %s\x01",cn,arg); char msg[MAX_LINE]; snprintf(msg,sizeof(msg),"* %s %s",s->nick,arg); chan_addline(&g_chans[g_active],F_ACTION,msg); } else if (strcmp(cmd,"/notice")==0) { char tgt[MAX_NICK],msg[MAX_LINE]; if(sscanf(arg,"%63s %[^\n]",tgt,msg)==2) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"NOTICE %s :%s",tgt,msg); srv_status_fmt(si,"->%s<- %s",tgt,msg); } else srv_status_msg(si,"Usage: /notice "); } else if (strcmp(cmd,"/ctcp")==0) { char tgt[MAX_NICK],ctcp[MAX_LINE]; if(sscanf(arg,"%63s %[^\n]",tgt,ctcp)>=1) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} for(char *c=ctcp;*c&&*c!=' ';c++) *c=toupper((unsigned char)*c); srv_sendf(si,"PRIVMSG %s :\x01%s\x01",tgt,ctcp); srv_status_fmt(si,"[ctcp] %s -> %s",ctcp,tgt); } else srv_status_msg(si,"Usage: /ctcp "); } else if (strcmp(cmd,"/names")==0) { const char *ch=arg[0]?arg:g_chans[g_active].name; if(ch[0]=='*'){srv_status_msg(si,"Not in a channel.");return;} if(s->connected) srv_sendf(si,"NAMES %s",ch); else { int ci=chan_find(si,ch); if(ci<0){srv_status_msg(si,"Channel not found.");return;} srv_status_fmt(si,"[names] %s (%d users):",ch,g_chans[ci].user_count); char line[MAX_LINE]; line[0]='\0'; for(int i=0;i60){ srv_status_fmt(si,"[names] %s",line); line[0]='\0'; } if(line[0]) strncat(line," ",MAX_LINE-1); strncat(line,g_chans[ci].users[i],MAX_LINE-1); } if(line[0]) srv_status_fmt(si,"[names] %s",line); } } else if (strcmp(cmd,"/topic")==0) { const char *ch=g_chans[g_active].name; if(ch[0]=='*'){srv_status_msg(si,"Not in a channel.");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} if(arg[0]) srv_sendf(si,"TOPIC %s :%s",ch,arg); else srv_sendf(si,"TOPIC %s",ch); } else if (strcmp(cmd,"/whois")==0) { if(!arg[0]){srv_status_msg(si,"Usage: /whois ");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"WHOIS %s",arg); } else if (strcmp(cmd,"/who")==0) { const char *tgt=arg[0]?arg:g_chans[g_active].name; if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"WHO %s",tgt); } else if (strcmp(cmd,"/list")==0) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_status_msg(si,"[list] Requesting channel list..."); if(arg[0]) srv_sendf(si,"LIST %s",arg); else srv_send_raw(si,"LIST"); } else if (strcmp(cmd,"/away")==0) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} if(arg[0]) srv_sendf(si,"AWAY :%s",arg); else srv_send_raw(si,"AWAY"); } else if (strcmp(cmd,"/back")==0) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_send_raw(si,"AWAY"); } else if (strcmp(cmd,"/invite")==0) { char tgt[MAX_NICK],ch[MAX_CHAN]; ch[0]='\0'; if(sscanf(arg,"%63s %63s",tgt,ch)<1){srv_status_msg(si,"Usage: /invite [#chan]");return;} if(!ch[0]) strncpy(ch,g_chans[g_active].name,MAX_CHAN-1); if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_sendf(si,"INVITE %s %s",tgt,ch); } else if (strcmp(cmd,"/kick")==0) { char tgt[MAX_NICK],reason[MAX_LINE]; reason[0]='\0'; if(sscanf(arg,"%63s %[^\n]",tgt,reason)<1){srv_status_msg(si,"Usage: /kick [reason]");return;} const char *ch=g_chans[g_active].name; if(ch[0]!='#'){srv_status_msg(si,"Not in a channel.");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} if(reason[0]) srv_sendf(si,"KICK %s %s :%s",ch,tgt,reason); else srv_sendf(si,"KICK %s %s",ch,tgt); } else if (strcmp(cmd,"/mode")==0) { if(!s->connected){srv_status_msg(si,"[not connected]");return;} if(!arg[0]) srv_sendf(si,"MODE %s",g_chans[g_active].name); else srv_sendf(si,"MODE %s",arg); } else if (strcmp(cmd,"/raw")==0||strcmp(cmd,"/quote")==0) { if(!arg[0]){srv_status_msg(si,"Usage: /raw ");return;} if(!s->connected){srv_status_msg(si,"[not connected]");return;} srv_send_raw(si,arg); srv_status_fmt(si,">> %s",arg); } else if (strcmp(cmd,"/server")==0) { /* /server host [port] — add a new server connection */ char newhost[MAX_HOST]; int newport=6697; if(sscanf(arg,"%255s %d",newhost,&newport)<1){srv_status_msg(si,"Usage: /server [port]");return;} int nsi=srv_alloc(); if(nsi<0){srv_status_msg(si,"Max servers reached.");return;} strncpy(g_srv[nsi].host,newhost,MAX_HOST-1); g_srv[nsi].port=newport; /* inherit nick from current server */ strncpy(g_srv[nsi].nick,s->nick,MAX_NICK-1); chan_add(nsi,"*status*"); start_net_thread(nsi); srv_status_fmt(nsi,"Connecting to %s:%d...",newhost,newport); } else if (strcmp(cmd,"/connect")==0) { g_srv[si].net_stop=1; start_net_thread(si); } else if (strcmp(cmd,"/ignore")==0) { if(!arg[0]) { if(!g_ignore_count){srv_status_msg(si,"Ignore list is empty.");return;} srv_status_msg(si,"Ignored nicks:"); for(int i=0;i send raw IRC line", " /ignore [nick] ignore (no arg = list)", " /unignore NICK unignore nick", " /clear clear scrollback", " /connect reconnect current server", " /quit [msg] quit", "Keys:", " Tab nick completion", " Ctrl+N/P next/prev channel", " PgUp/PgDn scroll chat", " Ctrl+W delete word left", " Up/Down input history", NULL }; for(int i=0;h[i];i++) srv_status_msg(si,h[i]); } else { srv_status_fmt(si,"Unknown command: %s (/help for list)",cmd); } } /* ── history ───────────────────────────────────────────────────────────────── */ static void hist_push(const char *text) { if(g_hist_countscroll+=ch2/2; return; } if (key==KEY_NPAGE) { int ch2; getmaxyx(win_chat,ch2,(int){0}); ch->scroll-=ch2/2; if(ch->scroll<0)ch->scroll=0; return; } if (key=='\n'||key=='\r'||key==KEY_ENTER) { if(g_input_len>0) { g_input[g_input_len]='\0'; char *p=g_input; while(*p==' ')p++; char *e=g_input+g_input_len-1; while(e>p&&*e==' ')*e--='\0'; if(*p) { hist_push(p); g_hist_pos=-1; process_input(p); } } g_input[0]='\0'; g_input_len=0; g_input_cur=0; return; } if (key==127||key==KEY_BACKSPACE||key==8) { 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--; } return; } if (key==23) { /* Ctrl+W */ int cur=g_input_cur; while(cur>0&&g_input[cur-1]==' ') cur--; while(cur>0&&g_input[cur-1]!=' ') cur--; int del=g_input_cur-cur; memmove(g_input+cur,g_input+g_input_cur,g_input_len-g_input_cur+1); g_input_len-=del; g_input_cur=cur; return; } if (key==KEY_DC) { if(g_input_cur0) { g_input_cur--; return; } if (key==KEY_RIGHT &&g_input_cur0){ if(g_hist_pos0){ 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; } else { g_hist_pos=-1; g_input[0]='\0'; g_input_len=0; g_input_cur=0; } return; } if (key>=32&&key<256) { if(g_input_len0) g_srv[si].port=cli_port; if(cli_tls>=0) g_srv[si].use_tls=cli_tls; if(cli_chan) srv_add_autojoin(si,cli_chan); if(cli_su) strncpy(g_srv[si].sasl_user,cli_su,MAX_NICK-1); if(cli_sp) strncpy(g_srv[si].sasl_pass,cli_sp,255); if(cli_nick) strncpy(g_srv[si].nick,cli_nick,MAX_NICK-1); } else if (g_srv_count==0) { /* no config, no --host: add default server */ srv_alloc(); } /* apply --nick / --channel / --tls to all servers that don't have their own value set (covers the no-host case) */ for (int i=0; i=0 && !cli_host) g_srv[i].use_tls = cli_tls; if (cli_chan && !cli_host) srv_add_autojoin(i, cli_chan); } /* create status channels */ for(int i=0;i