Files
MontaukOS/programs/src/irc/main.cpp
T

1050 lines
31 KiB
C++

/*
* main.cpp
* IRC client for MontaukOS
* Split-screen terminal UI with ANSI escape codes
* Copyright (c) 2025-2026 Daniel Hammer
*/
#include <montauk/syscall.h>
#include <montauk/string.h>
using montauk::slen;
using montauk::streq;
using montauk::starts_with;
using montauk::skip_spaces;
using montauk::strcpy;
using montauk::strncpy;
using montauk::memcpy;
using montauk::memmove;
// ---- Minimal snprintf (no libc available) ----
using va_list = __builtin_va_list;
#define va_start __builtin_va_start
#define va_end __builtin_va_end
#define va_arg __builtin_va_arg
struct PfState {
char* buf;
int pos;
int max;
};
static void pf_putc(PfState* st, char c) {
if (st->pos < st->max) st->buf[st->pos] = c;
st->pos++;
}
static void pf_putnum(PfState* st, unsigned long val, int base, int width, char pad, int neg) {
char tmp[24];
int i = 0;
const char* digits = "0123456789abcdef";
if (val == 0) { tmp[i++] = '0'; }
else { while (val > 0) { tmp[i++] = digits[val % base]; val /= base; } }
int total = (neg ? 1 : 0) + i;
if (neg && pad == '0') pf_putc(st, '-');
for (int w = total; w < width; w++) pf_putc(st, pad);
if (neg && pad != '0') pf_putc(st, '-');
while (i > 0) pf_putc(st, tmp[--i]);
}
static int vsnprintf(char* buf, int size, const char* fmt, va_list ap) {
PfState st;
st.buf = buf;
st.pos = 0;
st.max = size > 0 ? size - 1 : 0;
while (*fmt) {
if (*fmt != '%') { pf_putc(&st, *fmt++); continue; }
fmt++;
char pad = ' ';
if (*fmt == '0') { pad = '0'; fmt++; }
int width = 0;
while (*fmt >= '0' && *fmt <= '9') { width = width * 10 + (*fmt - '0'); fmt++; }
if (*fmt == 'l') fmt++;
switch (*fmt) {
case 'd': case 'i': {
long val = va_arg(ap, int);
int neg = 0; unsigned long uval;
if (val < 0) { neg = 1; uval = (unsigned long)(-val); }
else uval = (unsigned long)val;
pf_putnum(&st, uval, 10, width, pad, neg);
break;
}
case 'u': { unsigned val = va_arg(ap, unsigned); pf_putnum(&st, val, 10, width, pad, 0); break; }
case 'x': { unsigned val = va_arg(ap, unsigned); pf_putnum(&st, val, 16, width, pad, 0); break; }
case 's': {
const char* s = va_arg(ap, const char*);
if (!s) s = "(null)";
int slen = 0; while (s[slen]) slen++;
for (int w = slen; w < width; w++) pf_putc(&st, ' ');
for (int j = 0; j < slen; j++) pf_putc(&st, s[j]);
break;
}
case 'c': { char c = (char)va_arg(ap, int); pf_putc(&st, c); break; }
case '%': pf_putc(&st, '%'); break;
default: pf_putc(&st, '%'); pf_putc(&st, *fmt); break;
}
if (*fmt) fmt++;
}
if (size > 0) {
if (st.pos < size) st.buf[st.pos] = '\0';
else st.buf[size - 1] = '\0';
}
return st.pos;
}
static int snprintf(char* buf, int size, const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
int ret = vsnprintf(buf, size, fmt, ap);
va_end(ap);
return ret;
}
// Case-insensitive comparison for IRC commands
static bool streqi(const char* a, const char* b) {
while (*a && *b) {
char ca = *a, cb = *b;
if (ca >= 'A' && ca <= 'Z') ca += 32;
if (cb >= 'A' && cb <= 'Z') cb += 32;
if (ca != cb) return false;
a++; b++;
}
return *a == *b;
}
// ---- IP parsing (from shell) ----
static bool parse_ip(const char* s, uint32_t* out) {
uint32_t octets[4];
int idx = 0;
uint32_t val = 0;
bool hasDigit = false;
for (int i = 0; ; i++) {
char c = s[i];
if (c >= '0' && c <= '9') {
val = val * 10 + (c - '0');
if (val > 255) return false;
hasDigit = true;
} else if (c == '.' || c == '\0') {
if (!hasDigit || idx >= 4) return false;
octets[idx++] = val;
val = 0; hasDigit = false;
if (c == '\0') break;
} else return false;
}
if (idx != 4) return false;
*out = octets[0] | (octets[1] << 8) | (octets[2] << 16) | (octets[3] << 24);
return true;
}
static bool parse_uint16(const char* s, uint16_t* out) {
uint32_t val = 0;
if (*s == '\0') return false;
while (*s) {
if (*s < '0' || *s > '9') return false;
val = val * 10 + (*s - '0');
if (val > 65535) return false;
s++;
}
*out = (uint16_t)val;
return true;
}
// ---- Data structures ----
static constexpr int MAX_NICK_LEN = 32;
static constexpr int MAX_CHANNEL_LEN = 64;
static constexpr int MAX_LINE_LEN = 256;
static constexpr int MAX_DISPLAY_LINES = 512;
static constexpr int IRC_MAX_MSG = 512;
struct IrcState {
int fd;
uint32_t serverIp;
uint16_t serverPort;
char nick[MAX_NICK_LEN];
char channel[MAX_CHANNEL_LEN];
bool registered;
bool connected;
bool inChannel;
int nickRetries;
};
struct RecvBuffer {
char buf[2048];
int len;
};
struct DisplayLine {
char text[MAX_LINE_LEN];
int len;
};
struct MessageBuffer {
DisplayLine lines[MAX_DISPLAY_LINES];
int head;
int count;
int scrollOffset;
};
struct InputState {
char buf[512];
int pos;
int len;
};
struct TermState {
int cols;
int rows;
int msgAreaRows;
};
// ---- Terminal helpers ----
static int int_to_buf(char* buf, int n) {
if (n == 0) { buf[0] = '0'; return 1; }
char tmp[12]; int i = 0;
while (n > 0) { tmp[i++] = '0' + (n % 10); n /= 10; }
for (int j = 0; j < i; j++) buf[j] = tmp[i - 1 - j];
return i;
}
// ---- Screen buffer for flicker-free rendering ----
static constexpr int SCREEN_BUF_SIZE = 32768;
struct ScreenBuf {
char buf[SCREEN_BUF_SIZE];
int pos;
};
static ScreenBuf screen;
static void sb_reset() { screen.pos = 0; }
static void sb_putc(char c) {
if (screen.pos < SCREEN_BUF_SIZE - 1) screen.buf[screen.pos++] = c;
}
static void sb_puts(const char* s) {
while (*s && screen.pos < SCREEN_BUF_SIZE - 1) screen.buf[screen.pos++] = *s++;
}
static void sb_cursor_to(int row, int col) {
sb_puts("\033[");
char tmp[12]; int n = int_to_buf(tmp, row); for (int i = 0; i < n; i++) sb_putc(tmp[i]);
sb_putc(';');
n = int_to_buf(tmp, col); for (int i = 0; i < n; i++) sb_putc(tmp[i]);
sb_putc('H');
}
static void sb_flush() {
screen.buf[screen.pos] = '\0';
montauk::print(screen.buf);
}
// ---- Globals ----
static IrcState irc;
static RecvBuffer recvBuf;
static MessageBuffer msgBuf;
static InputState input;
static TermState term;
static bool running;
static bool dirty;
// ---- Message buffer ----
static void msg_add(const char* text) {
int idx = (msgBuf.head + msgBuf.count) % MAX_DISPLAY_LINES;
if (msgBuf.count >= MAX_DISPLAY_LINES) {
// Overwrite oldest
msgBuf.head = (msgBuf.head + 1) % MAX_DISPLAY_LINES;
} else {
msgBuf.count++;
}
DisplayLine& line = msgBuf.lines[idx];
int i = 0;
while (text[i] && i < MAX_LINE_LEN - 1) {
line.text[i] = text[i];
i++;
}
line.text[i] = '\0';
line.len = i;
// Auto-scroll to bottom when new message arrives
if (msgBuf.scrollOffset == 0 || msgBuf.count <= term.msgAreaRows) {
msgBuf.scrollOffset = 0;
}
dirty = true;
}
static void msg_add_fmt(const char* fmt, ...) {
char tmp[MAX_LINE_LEN];
va_list ap;
va_start(ap, fmt);
vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
msg_add(tmp);
}
static void msg_clear() {
msgBuf.head = 0;
msgBuf.count = 0;
msgBuf.scrollOffset = 0;
}
// ---- IRC send helpers ----
static void irc_send(const char* fmt, ...) {
char buf[IRC_MAX_MSG];
va_list ap;
va_start(ap, fmt);
int len = vsnprintf(buf, sizeof(buf) - 2, fmt, ap);
va_end(ap);
// Ensure \r\n termination
if (len > IRC_MAX_MSG - 3) len = IRC_MAX_MSG - 3;
buf[len] = '\r';
buf[len + 1] = '\n';
buf[len + 2] = '\0';
montauk::send(irc.fd, buf, len + 2);
}
// ---- Sanitize incoming text (strip control chars) ----
static void sanitize(char* dst, const char* src, int maxLen) {
int j = 0;
for (int i = 0; src[i] && j < maxLen - 1; i++) {
unsigned char c = (unsigned char)src[i];
if (c == 0x1B) {
// Skip ESC sequences entirely
continue;
}
if (c < 0x20 && c != ' ') continue;
dst[j++] = (char)c;
}
dst[j] = '\0';
}
// ---- IRC prefix parsing ----
// Extract nick from ":nick!user@host" prefix
static void parse_prefix_nick(const char* prefix, char* nick, int maxLen) {
int i = 0;
if (prefix[0] == ':') prefix++;
while (prefix[i] && prefix[i] != '!' && prefix[i] != '@' && prefix[i] != ' ' && i < maxLen - 1) {
nick[i] = prefix[i];
i++;
}
nick[i] = '\0';
}
// ---- IRC protocol handlers ----
static void irc_handle_ping(const char* params) {
char buf[IRC_MAX_MSG];
snprintf(buf, sizeof(buf), "PONG %s", params);
irc_send("%s", buf);
}
static void irc_handle_privmsg(const char* prefix, const char* params) {
char senderNick[MAX_NICK_LEN];
parse_prefix_nick(prefix, senderNick, sizeof(senderNick));
// Find the message text after " :"
const char* text = params;
// Skip target
while (*text && *text != ' ') text++;
text = skip_spaces(text);
if (*text == ':') text++;
char clean[MAX_LINE_LEN];
sanitize(clean, text, sizeof(clean));
// Check for CTCP ACTION (\001ACTION ... \001)
if (starts_with(clean, "\001ACTION ")) {
const char* action = clean + 8;
// Strip trailing \001
char actionBuf[MAX_LINE_LEN];
strncpy(actionBuf, action, sizeof(actionBuf));
int alen = slen(actionBuf);
if (alen > 0 && actionBuf[alen - 1] == '\001') actionBuf[alen - 1] = '\0';
msg_add_fmt("\033[35m* %s %s\033[0m", senderNick, actionBuf);
return;
}
// Color own nick green, others cyan
if (streq(senderNick, irc.nick)) {
msg_add_fmt("\033[1;32m<%s>\033[0m %s", senderNick, clean);
} else {
msg_add_fmt("\033[1;36m<%s>\033[0m %s", senderNick, clean);
}
}
static void irc_handle_notice(const char* prefix, const char* params) {
char senderNick[MAX_NICK_LEN];
if (prefix[0]) {
parse_prefix_nick(prefix, senderNick, sizeof(senderNick));
} else {
strcpy(senderNick, "*");
}
const char* text = params;
while (*text && *text != ' ') text++;
text = skip_spaces(text);
if (*text == ':') text++;
char clean[MAX_LINE_LEN];
sanitize(clean, text, sizeof(clean));
msg_add_fmt("\033[1m-%s-\033[0m %s", senderNick, clean);
}
static void irc_handle_join(const char* prefix, const char* params) {
char nick[MAX_NICK_LEN];
parse_prefix_nick(prefix, nick, sizeof(nick));
const char* chan = params;
if (*chan == ':') chan++;
if (streq(nick, irc.nick)) {
strncpy(irc.channel, chan, sizeof(irc.channel));
irc.inChannel = true;
msg_add_fmt("\033[33m* Now talking in %s\033[0m", chan);
} else {
msg_add_fmt("\033[33m* %s has joined %s\033[0m", nick, chan);
}
}
static void irc_handle_part(const char* prefix, const char* params) {
char nick[MAX_NICK_LEN];
parse_prefix_nick(prefix, nick, sizeof(nick));
const char* chan = params;
while (*chan && *chan != ' ') chan++; // get channel text
// Actually parse channel from params start
chan = params;
char chanBuf[MAX_CHANNEL_LEN];
int i = 0;
while (chan[i] && chan[i] != ' ' && i < MAX_CHANNEL_LEN - 1) {
chanBuf[i] = chan[i]; i++;
}
chanBuf[i] = '\0';
if (streq(nick, irc.nick)) {
irc.inChannel = false;
irc.channel[0] = '\0';
msg_add_fmt("\033[33m* You have left %s\033[0m", chanBuf);
} else {
msg_add_fmt("\033[33m* %s has left %s\033[0m", nick, chanBuf);
}
}
static void irc_handle_quit(const char* prefix, const char* params) {
char nick[MAX_NICK_LEN];
parse_prefix_nick(prefix, nick, sizeof(nick));
const char* reason = params;
if (*reason == ':') reason++;
char clean[MAX_LINE_LEN];
sanitize(clean, reason, sizeof(clean));
if (clean[0]) {
msg_add_fmt("\033[33m* %s has quit (%s)\033[0m", nick, clean);
} else {
msg_add_fmt("\033[33m* %s has quit\033[0m", nick);
}
}
static void irc_handle_nick(const char* prefix, const char* params) {
char oldNick[MAX_NICK_LEN];
parse_prefix_nick(prefix, oldNick, sizeof(oldNick));
const char* newNick = params;
if (*newNick == ':') newNick++;
if (streq(oldNick, irc.nick)) {
strncpy(irc.nick, newNick, sizeof(irc.nick));
msg_add_fmt("\033[33m* You are now known as %s\033[0m", newNick);
} else {
msg_add_fmt("\033[33m* %s is now known as %s\033[0m", oldNick, newNick);
}
}
static void irc_handle_numeric(int num, const char* params) {
// Extract trailing text (after the last " :")
const char* text = params;
const char* lastColon = nullptr;
for (int i = 0; text[i]; i++) {
if (text[i] == ':' && (i == 0 || text[i - 1] == ' ')) {
lastColon = text + i + 1;
}
}
switch (num) {
case 1: // RPL_WELCOME
irc.registered = true;
if (lastColon) {
char clean[MAX_LINE_LEN];
sanitize(clean, lastColon, sizeof(clean));
msg_add_fmt("\033[1m*** %s\033[0m", clean);
}
// Auto-join channel if specified
if (irc.channel[0]) {
irc_send("JOIN %s", irc.channel);
}
break;
case 433: // ERR_NICKNAMEINUSE
if (irc.nickRetries < 3) {
int nlen = slen(irc.nick);
if (nlen < MAX_NICK_LEN - 2) {
irc.nick[nlen] = '_';
irc.nick[nlen + 1] = '\0';
}
irc.nickRetries++;
irc_send("NICK %s", irc.nick);
msg_add_fmt("\033[33m* Nick in use, trying %s\033[0m", irc.nick);
} else {
msg_add("\033[31m*** Could not find available nickname\033[0m");
}
break;
case 332: // RPL_TOPIC
case 353: // RPL_NAMREPLY
case 366: // RPL_ENDOFNAMES
case 372: // RPL_MOTD
case 375: // RPL_MOTDSTART
case 376: // RPL_ENDOFMOTD
default:
if (lastColon) {
char clean[MAX_LINE_LEN];
sanitize(clean, lastColon, sizeof(clean));
msg_add_fmt("\033[1m*** %s\033[0m", clean);
}
break;
}
}
// ---- IRC line parser ----
static void irc_process_line(char* line) {
// Strip trailing \r\n
int len = slen(line);
while (len > 0 && (line[len - 1] == '\r' || line[len - 1] == '\n')) {
line[--len] = '\0';
}
if (len == 0) return;
char prefix[256] = "";
char* cmd = line;
// Parse optional prefix
if (line[0] == ':') {
cmd = line + 1;
int i = 0;
while (cmd[i] && cmd[i] != ' ') i++;
if (i > 0) {
memcpy(prefix, cmd, i < 255 ? i : 255);
prefix[i < 255 ? i : 255] = '\0';
}
cmd += i;
while (*cmd == ' ') cmd++;
}
// Extract command word
char command[32] = "";
{
int i = 0;
while (cmd[i] && cmd[i] != ' ' && i < 31) {
command[i] = cmd[i]; i++;
}
command[i] = '\0';
cmd += i;
while (*cmd == ' ') cmd++;
}
// cmd now points to params
// Handle PING first (highest priority)
if (streqi(command, "PING")) {
irc_handle_ping(cmd);
return;
}
// Check for numeric command
bool isNumeric = true;
for (int i = 0; command[i]; i++) {
if (command[i] < '0' || command[i] > '9') { isNumeric = false; break; }
}
if (isNumeric && command[0]) {
int num = 0;
for (int i = 0; command[i]; i++) num = num * 10 + (command[i] - '0');
irc_handle_numeric(num, cmd);
return;
}
if (streqi(command, "PRIVMSG")) irc_handle_privmsg(prefix, cmd);
else if (streqi(command, "NOTICE")) irc_handle_notice(prefix, cmd);
else if (streqi(command, "JOIN")) irc_handle_join(prefix, cmd);
else if (streqi(command, "PART")) irc_handle_part(prefix, cmd);
else if (streqi(command, "QUIT")) irc_handle_quit(prefix, cmd);
else if (streqi(command, "NICK")) irc_handle_nick(prefix, cmd);
else if (streqi(command, "PONG")) { /* Ignore PONG replies */ }
else if (streqi(command, "ERROR")) {
char clean[MAX_LINE_LEN];
sanitize(clean, cmd, sizeof(clean));
msg_add_fmt("\033[31m*** Error: %s\033[0m", clean);
}
}
// ---- TCP recv with fragment assembly ----
static void recv_process() {
char tmp[512];
int r = montauk::recv(irc.fd, tmp, sizeof(tmp));
if (r < 0) {
irc.connected = false;
msg_add("\033[31m*** Connection lost\033[0m");
return;
}
if (r == 0) return;
// Append to recv buffer
int space = (int)sizeof(recvBuf.buf) - recvBuf.len;
if (r > space) r = space;
memcpy(recvBuf.buf + recvBuf.len, tmp, r);
recvBuf.len += r;
// Scan for complete lines (\r\n)
int start = 0;
for (int i = 0; i < recvBuf.len - 1; i++) {
if (recvBuf.buf[i] == '\r' && recvBuf.buf[i + 1] == '\n') {
// Extract line [start..i)
int lineLen = i - start;
char lineStr[IRC_MAX_MSG];
if (lineLen > IRC_MAX_MSG - 1) lineLen = IRC_MAX_MSG - 1;
memcpy(lineStr, recvBuf.buf + start, lineLen);
lineStr[lineLen] = '\0';
irc_process_line(lineStr);
start = i + 2;
i++; // skip \n
}
}
// Move unprocessed remainder to front
if (start > 0) {
int remaining = recvBuf.len - start;
if (remaining > 0) {
memmove(recvBuf.buf, recvBuf.buf + start, remaining);
}
recvBuf.len = remaining;
}
// Prevent overflow if no \r\n found in a full buffer
if (recvBuf.len >= (int)sizeof(recvBuf.buf) - 1) {
recvBuf.len = 0;
}
}
// ---- UI rendering (buffered, single flush) ----
static void ui_render() {
sb_reset();
// Hide cursor during redraw
sb_puts("\033[?25l");
// Status bar (row 1)
sb_cursor_to(1, 1);
sb_puts("\033[7m\033[2K");
char status[256];
if (irc.connected) {
if (irc.inChannel) {
snprintf(status, sizeof(status), " IRC | %s | %s | Connected ", irc.nick, irc.channel);
} else {
snprintf(status, sizeof(status), " IRC | %s | (no channel) | Connected ", irc.nick);
}
} else {
snprintf(status, sizeof(status), " IRC | %s | Disconnected ", irc.nick);
}
sb_puts(status);
int statusLen = slen(status);
for (int i = statusLen; i < term.cols; i++) sb_putc(' ');
sb_puts("\033[0m");
// Message area (rows 2..N-2)
int startLine;
if (msgBuf.count <= term.msgAreaRows) {
startLine = 0;
} else {
startLine = msgBuf.count - term.msgAreaRows - msgBuf.scrollOffset;
if (startLine < 0) startLine = 0;
}
for (int r = 0; r < term.msgAreaRows; r++) {
sb_cursor_to(r + 2, 1);
sb_puts("\033[2K");
int msgIdx = startLine + r;
if (msgIdx < msgBuf.count) {
int realIdx = (msgBuf.head + msgIdx) % MAX_DISPLAY_LINES;
sb_puts(msgBuf.lines[realIdx].text);
}
}
// Separator (row N-1)
sb_cursor_to(term.rows - 1, 1);
sb_puts("\033[2K\033[90m");
for (int i = 0; i < term.cols; i++) sb_putc('-');
sb_puts("\033[0m");
// Input line (row N)
sb_cursor_to(term.rows, 1);
sb_puts("\033[2K\033[1m>\033[0m ");
for (int i = 0; i < input.len; i++) sb_putc(input.buf[i]);
// Position cursor at input insertion point
sb_cursor_to(term.rows, input.pos + 3);
// Single flush — entire screen in one print call
sb_flush();
}
// ---- User command processing ----
static void handle_user_input() {
input.buf[input.len] = '\0';
const char* text = input.buf;
if (text[0] != '/') {
// Plain message to current channel
if (!irc.inChannel) {
msg_add("\033[31m*** Not in a channel. Use /join #channel\033[0m");
return;
}
irc_send("PRIVMSG %s :%s", irc.channel, text);
// Echo own message
msg_add_fmt("\033[1;32m<%s>\033[0m %s", irc.nick, text);
return;
}
// Parse command
const char* cmd = text + 1;
if (starts_with(cmd, "join ") || starts_with(cmd, "JOIN ")) {
const char* chan = skip_spaces(cmd + 5);
if (*chan == '\0') {
msg_add("\033[31m*** Usage: /join #channel\033[0m");
return;
}
strncpy(irc.channel, chan, sizeof(irc.channel));
irc_send("JOIN %s", chan);
}
else if (starts_with(cmd, "part") && (cmd[4] == '\0' || cmd[4] == ' ')) {
if (!irc.inChannel) {
msg_add("\033[31m*** Not in a channel\033[0m");
return;
}
const char* reason = cmd[4] ? skip_spaces(cmd + 5) : "";
if (*reason) {
irc_send("PART %s :%s", irc.channel, reason);
} else {
irc_send("PART %s", irc.channel);
}
}
else if (starts_with(cmd, "msg ") || starts_with(cmd, "MSG ")) {
const char* rest = skip_spaces(cmd + 4);
char target[MAX_NICK_LEN];
int i = 0;
while (rest[i] && rest[i] != ' ' && i < MAX_NICK_LEN - 1) {
target[i] = rest[i]; i++;
}
target[i] = '\0';
const char* msg = skip_spaces(rest + i);
if (!target[0] || !*msg) {
msg_add("\033[31m*** Usage: /msg nick message\033[0m");
return;
}
irc_send("PRIVMSG %s :%s", target, msg);
msg_add_fmt("\033[1;35m-> %s:\033[0m %s", target, msg);
}
else if (starts_with(cmd, "nick ") || starts_with(cmd, "NICK ")) {
const char* newNick = skip_spaces(cmd + 5);
if (!*newNick) {
msg_add("\033[31m*** Usage: /nick newnick\033[0m");
return;
}
irc_send("NICK %s", newNick);
strncpy(irc.nick, newNick, sizeof(irc.nick));
}
else if (starts_with(cmd, "quit") && (cmd[4] == '\0' || cmd[4] == ' ')) {
const char* reason = cmd[4] ? skip_spaces(cmd + 5) : "";
if (*reason) {
irc_send("QUIT :%s", reason);
} else {
irc_send("QUIT :Leaving");
}
irc.connected = false;
running = false;
}
else if (starts_with(cmd, "me ") || starts_with(cmd, "ME ")) {
if (!irc.inChannel) {
msg_add("\033[31m*** Not in a channel\033[0m");
return;
}
const char* action = skip_spaces(cmd + 3);
irc_send("PRIVMSG %s :\001ACTION %s\001", irc.channel, action);
msg_add_fmt("\033[35m* %s %s\033[0m", irc.nick, action);
}
else if (starts_with(cmd, "raw ") || starts_with(cmd, "RAW ")) {
const char* raw = cmd + 4;
irc_send("%s", raw);
msg_add_fmt("\033[90m>> %s\033[0m", raw);
}
else if (streqi(cmd, "help") || starts_with(cmd, "help ")) {
msg_add("\033[1m--- Help ---\033[0m");
msg_add(" /join #channel - Join a channel");
msg_add(" /part [reason] - Leave current channel");
msg_add(" /msg nick text - Send private message");
msg_add(" /nick newnick - Change nickname");
msg_add(" /quit [reason] - Disconnect and exit");
msg_add(" /me action - Send action");
msg_add(" /raw text - Send raw IRC line");
msg_add(" /clear - Clear message area");
msg_add(" /help - Show this help");
msg_add(" Ctrl+Q - Quit");
msg_add(" PgUp/PgDn - Scroll messages");
}
else if (streqi(cmd, "clear")) {
msg_clear();
}
else {
char cmdWord[32];
int i = 0;
while (cmd[i] && cmd[i] != ' ' && i < 31) { cmdWord[i] = cmd[i]; i++; }
cmdWord[i] = '\0';
msg_add_fmt("\033[31m*** Unknown command: /%s (try /help)\033[0m", cmdWord);
}
}
// ---- Entry point ----
extern "C" void _start() {
// Parse arguments: <server_ip> <port> <nickname> [#channel]
char argbuf[256];
montauk::getargs(argbuf, sizeof(argbuf));
const char* arg = skip_spaces(argbuf);
if (*arg == '\0') {
montauk::print("Usage: irc <server> <port> <nickname> [#channel]\n");
montauk::print("Example: irc irc.libera.chat 6667 MontaukUser #general\n");
return;
}
// Parse host (IP or hostname)
char hostStr[128];
int i = 0;
while (arg[i] && arg[i] != ' ' && i < 127) { hostStr[i] = arg[i]; i++; }
hostStr[i] = '\0';
arg = skip_spaces(arg + i);
if (!parse_ip(hostStr, &irc.serverIp)) {
irc.serverIp = montauk::resolve(hostStr);
if (irc.serverIp == 0) {
montauk::print("Could not resolve: ");
montauk::print(hostStr);
montauk::putchar('\n');
return;
}
}
// Parse port
char portStr[16];
i = 0;
while (arg[i] && arg[i] != ' ' && i < 15) { portStr[i] = arg[i]; i++; }
portStr[i] = '\0';
arg = skip_spaces(arg + i);
if (!parse_uint16(portStr, &irc.serverPort)) {
montauk::print("Invalid port: ");
montauk::print(portStr);
montauk::putchar('\n');
return;
}
// Parse nickname
i = 0;
while (arg[i] && arg[i] != ' ' && i < MAX_NICK_LEN - 1) { irc.nick[i] = arg[i]; i++; }
irc.nick[i] = '\0';
arg = skip_spaces(arg + i);
if (!irc.nick[0]) {
montauk::print("Missing nickname\n");
return;
}
// Parse optional channel
i = 0;
while (arg[i] && arg[i] != ' ' && i < MAX_CHANNEL_LEN - 1) { irc.channel[i] = arg[i]; i++; }
irc.channel[i] = '\0';
// Initialize state
irc.fd = -1;
irc.registered = false;
irc.connected = false;
irc.inChannel = false;
irc.nickRetries = 0;
recvBuf.len = 0;
msgBuf.head = 0;
msgBuf.count = 0;
msgBuf.scrollOffset = 0;
input.pos = 0;
input.len = 0;
running = true;
// Get terminal size
montauk::termsize(&term.cols, &term.rows);
term.msgAreaRows = term.rows - 3;
if (term.msgAreaRows < 1) term.msgAreaRows = 1;
// Enter alternate screen buffer, hide cursor
montauk::print("\033[?1049h");
montauk::print("\033[?25l");
// Initial draw
msg_add("\033[1m*** MontaukOS IRC Client\033[0m");
msg_add_fmt("*** Connecting to %s:%d as %s...", hostStr, (int)irc.serverPort, irc.nick);
ui_render();
// Create socket and connect
irc.fd = montauk::socket(montauk::abi::SOCK_TCP);
if (irc.fd < 0) {
msg_add("\033[31m*** Failed to create socket\033[0m");
ui_render();
// Wait for keypress then exit
while (!montauk::is_key_available()) montauk::yield();
goto cleanup;
}
if (montauk::connect(irc.fd, irc.serverIp, irc.serverPort) < 0) {
msg_add("\033[31m*** Connection failed\033[0m");
ui_render();
montauk::closesocket(irc.fd);
irc.fd = -1;
while (!montauk::is_key_available()) montauk::yield();
goto cleanup;
}
irc.connected = true;
msg_add("\033[32m*** Connected!\033[0m");
// Send IRC registration
irc_send("NICK %s", irc.nick);
irc_send("USER %s 0 * :%s", irc.nick, irc.nick);
ui_render();
// ---- Main loop ----
while (running && irc.connected) {
dirty = false;
// Process incoming data
int prevCount = msgBuf.count;
recv_process();
if (msgBuf.count != prevCount) dirty = true;
// Poll keyboard
if (montauk::is_key_available()) {
montauk::abi::KeyEvent ev;
montauk::getkey(&ev);
if (!ev.pressed) {
if (dirty) ui_render();
continue;
}
dirty = true;
// Ctrl+Q to quit
if (ev.ctrl && (ev.ascii == 'q' || ev.ascii == 'Q')) {
if (irc.connected) {
irc_send("QUIT :Leaving");
}
running = false;
continue;
}
// Handle special keys by scancode
switch (ev.scancode) {
case 0x49: // Page Up
if (msgBuf.scrollOffset < msgBuf.count - term.msgAreaRows) {
msgBuf.scrollOffset += term.msgAreaRows / 2;
int maxScroll = msgBuf.count - term.msgAreaRows;
if (maxScroll < 0) maxScroll = 0;
if (msgBuf.scrollOffset > maxScroll) msgBuf.scrollOffset = maxScroll;
}
break;
case 0x51: // Page Down
msgBuf.scrollOffset -= term.msgAreaRows / 2;
if (msgBuf.scrollOffset < 0) msgBuf.scrollOffset = 0;
break;
case 0x47: // Home (scroll to top)
if (msgBuf.count > term.msgAreaRows) {
msgBuf.scrollOffset = msgBuf.count - term.msgAreaRows;
}
break;
case 0x4F: // End (scroll to bottom)
msgBuf.scrollOffset = 0;
break;
default:
if (ev.ascii == '\n') {
if (input.len > 0) {
input.buf[input.len] = '\0';
handle_user_input();
input.pos = 0;
input.len = 0;
}
} else if (ev.ascii == '\b') {
if (input.pos > 0) {
for (int j = input.pos - 1; j < input.len - 1; j++) {
input.buf[j] = input.buf[j + 1];
}
input.pos--;
input.len--;
}
} else if (ev.ascii >= ' ' && ev.ascii <= '~') {
if (input.len < 510) {
for (int j = input.len; j > input.pos; j--) {
input.buf[j] = input.buf[j - 1];
}
input.buf[input.pos] = ev.ascii;
input.pos++;
input.len++;
}
} else {
dirty = false; // Unhandled key, no redraw needed
}
break;
}
} else {
if (!dirty) {
montauk::wait_handle(irc.fd,
montauk::abi::IPC_SIGNAL_READABLE | montauk::abi::IPC_SIGNAL_PEER_CLOSED,
10);
continue;
}
}
if (dirty) ui_render();
}
if (irc.fd >= 0) {
montauk::closesocket(irc.fd);
}
cleanup:
// Restore terminal
montauk::print("\033[?25h");
montauk::print("\033[?1049l");
}