diff options
| author | emmett1 <emmett1.2miligrams@protonmail.com> | 2026-03-29 16:27:28 +0800 |
|---|---|---|
| committer | emmett1 <emmett1.2miligrams@protonmail.com> | 2026-03-29 16:27:28 +0800 |
| commit | aea011ef4542dae9998a4c00c9bb02730aaa690c (patch) | |
| tree | 87e949c6823e9fcbc852dedd4a22c36ecc314708 | |
initial commit
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | makefile | 21 | ||||
| -rw-r--r-- | readme.txt | 252 | ||||
| -rw-r--r-- | sirc.c | 2037 |
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 @@ -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)", |
