sirc

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit aea011ef4542dae9998a4c00c9bb02730aaa690c
Author: emmett1 <emmett1.2miligrams@protonmail.com>
Date:   Sun, 29 Mar 2026 16:27:28 +0800

initial commit

Diffstat:
A.gitignore | 2++
Amakefile | 21+++++++++++++++++++++
Areadme.txt | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asirc.c | 2037+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 2312 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +sirc.o +sirc diff --git a/makefile b/makefile @@ -0,0 +1,21 @@ +CC = cc +CFLAGS = -std=c99 -Wall -Wextra -Wpedantic -O2 +LDFLAGS = -lncurses -lpthread -lssl -lcrypto + +TARGET = sirc +SRC = sirc.c + +.PHONY: all clean install + +all: $(TARGET) + +$(TARGET): $(SRC) + $(CC) $(CFLAGS) -std=gnu99 -o $@ $^ $(LDFLAGS) + +clean: + rm -f $(TARGET) + +install: $(TARGET) + install -Dm755 $(TARGET) $(DESTDIR)$(PREFIX)/bin/$(TARGET) + +PREFIX ?= $(HOME)/.local diff --git a/readme.txt b/readme.txt @@ -0,0 +1,252 @@ +sirc - simple irc +================= + +A simple multi-server terminal IRC client written in C. Single source file, no runtime +dependencies beyond ncurses, OpenSSL, and pthreads. + + +LAYOUT +------ + ++------------------+--------------------------------------------------+----------------+ +| CHANNELS | Don't paste spam. Use a pastebin. Be excellent. | USERS (42) | +| +--------------------------------------------------+ | +| irc.libera.chat | 12:34 -> alice joined | @ChanServ | +| *status* | 12:34 <kky> hello everyone | @alice | +| > #python | 12:34 <alice> hey! | +bob | +| + #linux | | charlie | +| | | | +| irc.oftc.net | | | +| *status* | | | +| #debian | > _ | | ++------------------+--------------------------------------------------+----------------+ + +Header bar -- topic of the active channel. Blank if no topic is set. + Starts from the left, truncated on the right if too long. + +Channel list -- grouped by server hostname. A ~ prefix on the server name + means disconnected. Channel indicators: + > active channel + + unread messages + ! unread mention of your nick + *status* appears under each server for system/server messages. + Reachable via Ctrl+N/P like any other buffer. + +Chat area -- colour scheme: + <nick> per-nick hashed colour (bold) + message body plain white + own messages yellow bold + mentions red bold + actions (/me) magenta italic + join/part gray + URLs blue underline + IRC formatting codes (^B bold, ^C colour, etc.) are stripped + before display. + +User list -- sorted by privilege then alphabetically within each group: + ~ founder/owner (red bold) + & protected op (red bold) + @ op (red bold) + % halfop (yellow bold) + + voice (yellow bold) + regular (white) + +Input -- prompt shows "> " with a blinking cursor as you type. + + +BUILD +----- + +Dependencies: + + Library Arch Debian/Ubuntu + ------- ---- ------------- + ncurses ncurses libncurses-dev + OpenSSL openssl libssl-dev + pthreads (glibc) (glibc) + +Build: + + make + +Install to ~/.local/bin/irc: + + make install + +System-wide: + + PREFIX=/usr/local make install + + +USAGE +----- + + ./irc [options] + +CLI options configure a single server. For multiple servers use a config file. + + --host HOST server hostname (default: irc.libera.chat) + --port PORT server port (default: 6697) + --nick NICK nickname (default: circ_user) + --channel CHAN channel(s) to join, comma-separated + --tls enable TLS (default: on) + --no-tls disable TLS (plain, typically port 6667) + --sasl-user USER SASL username + --sasl-pass PASS SASL password + --config FILE path to config file + +Examples: + + # TLS with SASL + ./irc --host irc.libera.chat --nick kky --sasl-user kky --sasl-pass hunter2 + + # Plain text + ./irc --host irc.libera.chat --port 6667 --no-tls --nick kky + + # Join multiple channels + ./irc --host irc.libera.chat --nick kky --channel '#python,#linux' + + +CONFIG FILE +----------- + +Loaded from ~/.ircrc or ~/.config/irc/ircrc (first found wins). +CLI flags override the first server in the config. + +Use [server] blocks for multiple servers. Each block is an independent +connection with its own nick, channels, and credentials. All servers +connect simultaneously on startup. + + # Global defaults (before any [server] block) + nick = kky + ignore = badbot,spammer + + [server] + host = irc.libera.chat + port = 6697 + tls = true + channel = #python,#linux + sasl_user = kky + sasl_pass = hunter2 + + [server] + host = irc.oftc.net + port = 6697 + tls = true + channel = #debian,#tor + + [server] + host = irc.rizon.net + port = 6667 + tls = false + nick = kky_rizon + channel = #rice + +Per [server] keys: + + host server hostname + port port number + nick nickname for this server + channel comma-separated channels to auto-join + tls true / false + sasl_user SASL PLAIN username + sasl_pass SASL PLAIN password + +Global keys (before any [server] block): + + nick default nick inherited by all servers + ignore comma-separated nicks to ignore globally + + +COMMANDS +-------- + +All commands act on the current server (server of the active channel). + +Messaging: + /msg <nick> <text> open a private message window + /notice <target> <text> send a NOTICE + /me <text> CTCP ACTION (* nick text) + /ctcp <nick> <cmd> send a raw CTCP request + +Channels: + /join [#chan] join a channel + /part [#chan] leave a channel (defaults to current) + /cycle [#chan] part and immediately rejoin + /names [#chan] list users in a channel + /topic [text] get the current topic, or set a new one + /invite <nick> [#chan] invite someone to a channel + /kick <nick> [reason] kick a user from the current channel + /mode [target] [modes] get or set channel/user modes + +Users: + /nick show your current nickname + /nick <n> change your nickname + /whois <nick> full whois lookup + /who [target] WHO query on a channel or nick + /away [message] set an away message + /back clear away status + /ignore [nick] ignore a nick (no arg = list ignored nicks) + /unignore <nick> stop ignoring a nick + +Server: + /list [pattern] list channels on the current server + /raw <line> send a raw IRC line (alias: /quote) + /server <host> [port] connect to an additional server at runtime + /connect reconnect the current server + /quit [message] disconnect all servers and exit + +UI: + /clear clear the current channel's scrollback + /help show command reference in *status* + + +KEYS +---- + + Tab nick completion -- cycle through matches + colon suffix (nick: ) added at start of line + Ctrl+N next channel + Ctrl+P previous channel + PgUp scroll chat up + PgDn scroll chat down + Ctrl+W delete word left + Up / Down step through input history + Left / Right move cursor + Home / End jump to start/end of input + Delete delete character under cursor + + +FEATURES +-------- + + - Multiple simultaneous servers, each with its own thread, nick, channels + - TLS via OpenSSL with certificate verification (SSL_VERIFY_PEER) + - SASL PLAIN authentication per server, before registration + - Auto-reconnect with 5-second backoff, rejoins all open channels + - Per-nick colours: djb2 hash -> 8 colours, consistent in chat and user list + - IRC formatting stripped: bold, colour, italic, underline, reset, etc. + - Topic shown in header bar, updated live on TOPIC messages + - URLs (http://, https://, www.) rendered with blue underline + - Nick tab-completion with cycling + - Ring buffer scrollback: 500 lines per channel, O(1) insert + - Global ignore list, loadable from config + + +LIMITS +------ + + Max servers 8 + Max channels (total) 128 + Users per channel 512 + Scrollback per channel 500 lines + Input line length 480 chars + Input history 256 lines + Ignore list 64 nicks + Auto-join channels/server 16 + + +LICENSE +------- + +MIT diff --git a/sirc.c b/sirc.c @@ -0,0 +1,2037 @@ +/* + * sirc.c — multi-server terminal IRC client + * + * Usage: sirc [options] + * + * Config (~/.sircrc): + * # 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 <line> 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 <string.h> +#include <stdio.h> +#include <stdlib.h> +#include <stdarg.h> +#include <ctype.h> +#include <time.h> +#include <errno.h> +#include <unistd.h> +#include <fcntl.h> +#include <sys/socket.h> +#include <netdb.h> +#include <pthread.h> +#include <signal.h> +#include <netinet/in.h> +#include <arpa/inet.h> +#include <ncurses.h> +#include <openssl/ssl.h> +#include <openssl/err.h> + +/* ── 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]; +} 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 +} 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; + + /* 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 "<prefix><nick>" 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 */ +static int chan_next(int cur, int dir) { + if (g_chan_count <= 1) return cur; + return (cur + dir + g_chan_count) % g_chan_count; +} + +/* ── 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 i=0, j=0; + while (i < inlen) { + unsigned int a=i<inlen?(unsigned char)in[i++]:0; + unsigned int b=i<inlen?(unsigned char)in[i++]:0; + unsigned int c=i<inlen?(unsigned char)in[i++]:0; + unsigned int n=(a<<16)|(b<<8)|c; + out[j++]=b64tab[(n>>18)&63]; out[j++]=b64tab[(n>>12)&63]; + out[j++]=(i-2<=inlen)?b64tab[(n>>6)&63]:'='; + out[j++]=(i-1<=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 */ + if (strcmp(m.cmd,"CAP")==0) { + char sub[16]=""; + if (m.nparams>=2) str_upper(sub, m.params[1], sizeof(sub)); + if (strcmp(sub,"ACK")==0 && strstr(m.trail,"sasl")) { + srv_send_raw(si, "AUTHENTICATE PLAIN"); + } else if (strcmp(sub,"NAK")==0) { + ev_simple(EV_ERROR, si, NULL, NULL, "Server rejected SASL CAP."); + srv_sendf(si, "NICK %s", s->nick); + srv_sendf(si, "USER %s 0 * :circ", s->nick); + } + return; + } + if (strcmp(m.cmd,"AUTHENTICATE")==0 && strcmp(m.trail,"+")==0) { + char payload[512]; + int plen=snprintf(payload,sizeof(payload),"%s%c%s%c%s", + s->sasl_user,0,s->sasl_user,0,s->sasl_pass); + char enc[700]; base64_encode((unsigned char*)payload,plen,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 * :circ", 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 * :circ", 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) 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; + if (sasl_pending) + srv_send_raw(si,"CAP REQ :sasl"); + else { + srv_sendf(si,"NICK %s",s->nick); + srv_sendf(si,"USER %s 0 * :circ",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; i<n; i++) { + char c=readbuf[i]; + if (c=='\n') { + if (linelen>0 && 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,"sirc_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[512]; strncpy(tmp,chanlist,511); + char *tok=strtok(tmp,","); + while (tok) { + while (*tok==' ') tok++; + 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]='#'; } + 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/.ircrc",home); + snprintf(candidates[1],MAX_HOST,"%s/.config/irc/ircrc",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]="sirc_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; si<g_srv_count && row<H-1; si++) { + /* server header: full hostname, ~ prefix if disconnected */ + char hdr[CHAN_W]; + const char *conn_mark = g_srv[si].connected ? "" : "~"; + snprintf(hdr,sizeof(hdr),"%s%.*s",conn_mark,(int)(CHAN_W-2),g_srv[si].host); + int hl=strlen(hdr); + while (hl<CHAN_W-1) hdr[hl++]=' '; hdr[CHAN_W-1]='\0'; + wattron(win_chan,COLOR_PAIR(C_SERVER_HDR)|A_BOLD); + mvwaddnstr(win_chan,row,0,hdr,CHAN_W-1); + wattroff(win_chan,COLOR_PAIR(C_SERVER_HDR)|A_BOLD); + row++; + + /* show *status* first, then regular channels */ + for (int pass=0; pass<2 && row<H-1; pass++) { + for (int ci=0; ci<g_chan_count && row<H-1; ci++) { + if (g_chans[ci].srv!=si) continue; + int is_status=(strcmp(g_chans[ci].name,"*status*")==0); + if (pass==0 && !is_status) continue; /* pass 0: status only */ + if (pass==1 && is_status) continue; /* pass 1: channels only */ + Channel *ch=&g_chans[ci]; + int attr; const char *pfx; + if (ci==g_active) { + attr=COLOR_PAIR(C_CHAN_SEL)|A_BOLD; pfx="> "; + } else if (is_status) { + attr=COLOR_PAIR(C_STATUS)|A_DIM; pfx=" "; + } else if (ch->mention) { + 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 (ll<CHAN_W-1) label[ll++]=' '; label[CHAN_W-1]='\0'; + wattron(win_chan,attr); + mvwaddnstr(win_chan,row,0,label,CHAN_W-1); + wattroff(win_chan,attr); + row++; + } + } + } + + /* bottom: active channel name */ + char hint[CHAN_W]; + snprintf(hint,sizeof(hint),"%.*s",CHAN_W-1,g_chans[g_active].name); + int hl=strlen(hint); + while (hl<CHAN_W-1) hint[hl++]=' '; hint[CHAN_W-1]='\0'; + wattron(win_chan,COLOR_PAIR(C_STATUS)|A_DIM); + mvwaddnstr(win_chan,H-1,0,hint,CHAN_W-1); + wattroff(win_chan,COLOR_PAIR(C_STATUS)|A_DIM); + + wnoutrefresh(win_chan); +} + +static void draw_chat(void) { + int chat_h,chat_w; getmaxyx(win_chat,chat_h,chat_w); + werase(win_chat); + Channel *ch=&g_chans[g_active]; + if (ch->line_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; li<ch->line_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; i<vis_end; i++) { + int row=i-vis_start; + FlatLine *fl=&flat[i]; + if (fl->flag==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&&gt) { + 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; i<ch->user_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;i<C_NICK_COUNT;i++) + init_pair(C_NICK_BASE+i,nick_cols[i],-1); +} + +static void build_windows(void) { + int H,W; getmaxyx(stdscr,H,W); + int chat_w=W-CHAN_W-USER_W, chat_h=H-3; + if(win_chan) delwin(win_chan); + if(win_chat) delwin(win_chat); + if(win_users) delwin(win_users); + if(win_input) delwin(win_input); + if(win_status) delwin(win_status); + win_status=newwin(1, W, 0, 0); + win_chan =newwin(H, CHAN_W, 0, 0); + win_chat =newwin(chat_h,chat_w,1, CHAN_W); + win_users =newwin(H, USER_W,0, CHAN_W+chat_w); + win_input =newwin(1, chat_w,H-1,CHAN_W); +} + +/* ── event handler ─────────────────────────────────────────────────────────── */ + +static void do_rejoin_channels(int si) { + int any=0; + for (int i=0;i<g_chan_count;i++) { + if (g_chans[i].srv==si && g_chans[i].name[0]=='#') { + srv_sendf(si,"JOIN %s",g_chans[i].name); any=1; + } + } + if (!any) { + for (int j=0;j<g_srv[si].autojoin_count;j++) + srv_sendf(si,"JOIN %s",g_srv[si].autojoin[j]); + } +} + +static void handle_event(const Event *ev) { + int si=ev->srv; + 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;i<g_chan_count;i++) { + if (g_chans[i].srv!=si) continue; + int found=0; + for (int j=0;j<g_chans[i].user_count;j++) + if (strcasecmp(g_chans[i].users[j],ev->nick)==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;i<g_chan_count;i++) { + if (g_chans[i].srv!=si) continue; + for (int j=0;j<g_chans[i].user_count;j++) { + /* skip stored mode prefix when comparing */ + const char *sn=g_chans[i].users[j]; + if (*sn=='@'||*sn=='+'||*sn=='~'||*sn=='&'||*sn=='%'||*sn==' ') sn++; + if (strcasecmp(sn,ev->nick)==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; + 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; + } + + } +} + +/* ── 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;i<ch->user_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_active<g_chan_count) return g_chans[g_active].srv; + return 0; +} + +static void process_input(const char *text) { + int si=active_srv(); + Server *s=&g_srv[si]; + + if (text[0]!='/') { + if (g_active<0) { srv_status_msg(si,"Use /join #channel first."); return; } + const char *cn=g_chans[g_active].name; + if (cn[0]=='*') { srv_status_msg(si,"Use /join #channel first."); return; } + if (!s->connected) { 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 <target> <message>"); + + } 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 <nick> <command>"); + + } 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;i<g_chans[ci].user_count;i++){ + if(strlen(line)+strlen(g_chans[ci].users[i])+2>60){ + 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 <nick>");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 <nick> [#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 <nick> [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 <irc line>");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 <host> [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<g_ignore_count;i++) srv_status_fmt(si," %s",g_ignore[i]); + return; + } + ignore_add(arg); srv_status_fmt(si,"Ignoring %s",arg); + + } else if (strcmp(cmd,"/unignore")==0) { + if(is_ignored(arg)){ignore_remove(arg);srv_status_fmt(si,"Unignored %s",arg);} + else srv_status_fmt(si,"%s is not ignored",arg); + + } else if (strcmp(cmd,"/clear")==0) { + g_chans[g_active].line_count=0; + g_chans[g_active].line_head=0; + g_chans[g_active].scroll=0; + + } else if (strcmp(cmd,"/quit")==0) { + for (int i=0;i<g_srv_count;i++) + if(g_srv[i].connected) srv_sendf(i,"QUIT :%s",arg[0]?arg:"Goodbye"); + endwin(); exit(0); + + } else if (strcmp(cmd,"/help")==0) { + static const char *h[]={ + "Commands:", + " /join #chan join channel", + " /part [#chan] leave channel", + " /cycle [#chan] part and rejoin", + " /nick NAME change nick", + " /msg NICK TEXT private message", + " /me TEXT action message", + " /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", + " /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 <line> 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_count<MAX_HIST_LINES) strncpy(g_hist[g_hist_count++].text,text,MAX_INPUT-1); + 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); } +} + +/* ── key handler ───────────────────────────────────────────────────────────── */ + +static void handle_key(int key) { + if (key==KEY_RESIZE) { clear(); build_windows(); return; } + if (key=='\t') { tab_complete(); return; } + tab_reset(); + + Channel *ch=&g_chans[g_active]; + + if (key==14) { g_active=chan_next(g_active,1); g_chans[g_active].unread=0; g_chans[g_active].mention=0; return; } + if (key==16) { g_active=chan_next(g_active,-1); g_chans[g_active].unread=0; g_chans[g_active].mention=0; return; } + + if (key==KEY_PPAGE) { int ch2; getmaxyx(win_chat,ch2,(int){0}); ch->scroll+=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_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; } + + if (key==KEY_LEFT &&g_input_cur>0) { g_input_cur--; return; } + if (key==KEY_RIGHT &&g_input_cur<g_input_len) { g_input_cur++; return; } + if (key==KEY_HOME) { g_input_cur=0; return; } + if (key==KEY_END) { g_input_cur=g_input_len; return; } + + if (key==KEY_UP) { + 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; } + return; + } + if (key==KEY_DOWN) { + 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; } + 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_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++; } + return; + } +} + +/* ── main ──────────────────────────────────────────────────────────────────── */ + +static void usage(const char *prog) { + fprintf(stderr, + "Usage: %s [options]\n" + " --host HOST server hostname\n" + " --port PORT port (default 6697)\n" + " --nick NICK nickname\n" + " --channel CHAN channel(s) to join, comma-separated\n" + " --tls / --no-tls TLS on/off (default on)\n" + " --sasl-user USER SASL username\n" + " --sasl-pass PASS SASL password\n" + " --config FILE config file\n" + "\n" + "Multiple servers via ~/.ircrc [server] blocks.\n", prog); +} + +int main(int argc, char **argv) { + const char *config_path=NULL; + for(int i=1;i<argc-1;i++) if(strcmp(argv[i],"--config")==0){config_path=argv[i+1];break;} + load_config(config_path); + + /* CLI server (only if --host given, or if no servers loaded yet) */ + const char *cli_host=NULL; + int cli_port=-1; int cli_tls=-1; + const char *cli_nick=NULL, *cli_chan=NULL, *cli_su=NULL, *cli_sp=NULL; + for(int i=1;i<argc;i++) { + if (strcmp(argv[i],"--host")==0 &&i+1<argc) cli_host=argv[++i]; + else if (strcmp(argv[i],"--port")==0 &&i+1<argc) cli_port=atoi(argv[++i]); + else if (strcmp(argv[i],"--nick")==0 &&i+1<argc) cli_nick=argv[++i]; + else if (strcmp(argv[i],"--channel")==0 &&i+1<argc) cli_chan=argv[++i]; + else if (strcmp(argv[i],"--tls")==0) cli_tls=1; + else if (strcmp(argv[i],"--no-tls")==0) cli_tls=0; + else if (strcmp(argv[i],"--sasl-user")==0&&i+1<argc) cli_su=argv[++i]; + else if (strcmp(argv[i],"--sasl-pass")==0&&i+1<argc) cli_sp=argv[++i]; + else if (strcmp(argv[i],"--config")==0) i++; + else if (strcmp(argv[i],"--help")==0) { usage(argv[0]); return 0; } + } + + /* if CLI gave a host, either override first server or add new one */ + if (cli_host) { + int si; + if (g_srv_count==0) si=srv_alloc(); + else si=0; /* override first */ + strncpy(g_srv[si].host,cli_host,MAX_HOST-1); + if(cli_port>0) g_srv[si].port=cli_port; + if(cli_nick) strncpy(g_srv[si].nick,cli_nick,MAX_NICK-1); + if(cli_tls>=0) g_srv[si].use_tls=cli_tls; + 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_chan) srv_add_autojoin(si,cli_chan); + } else if (g_srv_count==0) { + /* no config, no --host: add default server */ + srv_alloc(); + } + + /* create status channels */ + for(int i=0;i<g_srv_count;i++) chan_add(i,"*status*"); + + /* event pipe */ + if(pipe(g_evpipe)<0){perror("pipe");return 1;} + fcntl(g_evpipe[1],F_SETFL,O_NONBLOCK); + fcntl(g_evpipe[0],F_SETFL,O_NONBLOCK); + signal(SIGPIPE,SIG_IGN); + + initscr(); raw(); noecho(); + keypad(stdscr,TRUE); nodelay(stdscr,TRUE); curs_set(2); + init_colors(); build_windows(); + + /* start all server threads */ + for(int i=0;i<g_srv_count;i++) start_net_thread(i); + + while(1) { + Event ev; + while(read(g_evpipe[0],&ev,sizeof(Event))==(ssize_t)sizeof(Event)) + handle_event(&ev); + draw_all(); + int key=getch(); + if(key!=ERR) handle_key(key); + struct timespec ts={0,20000000}; + nanosleep(&ts,NULL); + } + endwin(); return 0; +}