aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoremmett1 <emmett1.2miligrams@protonmail.com>2026-03-29 16:27:28 +0800
committeremmett1 <emmett1.2miligrams@protonmail.com>2026-03-29 16:27:28 +0800
commitaea011ef4542dae9998a4c00c9bb02730aaa690c (patch)
tree87e949c6823e9fcbc852dedd4a22c36ecc314708
initial commit
-rw-r--r--.gitignore2
-rw-r--r--makefile21
-rw-r--r--readme.txt252
-rw-r--r--sirc.c2037
4 files changed, 2312 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5971120
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+sirc.o
+sirc
diff --git a/makefile b/makefile
new file mode 100644
index 0000000..e812067
--- /dev/null
+++ 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
new file mode 100644
index 0000000..3d5bccb
--- /dev/null
+++ 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
new file mode 100644
index 0000000..ff78cde
--- /dev/null
+++ 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)",