#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "charset.h" static size_t display_cols(const char *buf, size_t bytes); #define BUF_SIZE 4096 #define IRC_MAX 512 /* Window levels: 0=status+msg, 1-7=channels */ #define WL_STATUS 0 #define WL_MSG 0 #define WL_CHAN 1 #define WL_MAX 9 #define MAX_CHAN_WINS 8 #define SCROLLBACK 500 static int current_level = WL_STATUS; static int scroll_offset = 0; /* 0 = bottom (live), >0 = scrolled up */ static int win_activity[WL_MAX]; /* activity flag per window */ static volatile sig_atomic_t got_sigint = 0; static volatile sig_atomic_t got_sigwinch = 0; static void sigint_handler(int sig) { (void)sig; got_sigint = 1; } static void sigwinch_handler(int sig) { (void)sig; got_sigwinch = 1; } static int sock_fd = -1; static char recv_buf[BUF_SIZE]; static size_t recv_len = 0; static char nick[64] = "kiro_user"; static int term_rows = 24, term_cols = 80; static struct termios orig_term; /* Per-window scrollback buffer */ static struct { char lines[SCROLLBACK][512]; int count; /* total lines stored (up to SCROLLBACK) */ int head; /* next write position (circular) */ } win_buf[WL_MAX]; /* Per-channel nick list */ #define MAX_NICKS 256 #define NICK_LEN 32 static struct { char name[128]; /* channel name */ char modes[64]; /* channel modes (e.g. "+nt") */ char my_prefix; /* '@', '+', or '\0' */ char nicks[MAX_NICKS][NICK_LEN]; int nick_count; } win_chans[MAX_CHAN_WINS]; /* Query target (for /q) */ static char query_target[64] = ""; static int translate_public = 0; /* /trans toggle: echo translations to channel */ static int translate_enabled = 1; /* master toggle for translation */ static int irc_colors = 1; /* display IRC colour codes as ANSI */ static char rent_msg[256] = "This space available for rent"; /* Track nicks who sent us private messages */ #define MAX_PM_NICKS 32 static char pm_nicks[MAX_PM_NICKS][NICK_LEN]; static int pm_nick_count = 0; static void pm_nick_add(const char *n) { /* Move to front if already present */ for (int i = 0; i < pm_nick_count; i++) { if (strcasecmp(pm_nicks[i], n) == 0) { if (i == 0) return; char tmp[NICK_LEN]; memcpy(tmp, pm_nicks[i], NICK_LEN); memmove(pm_nicks[1], pm_nicks[0], i * NICK_LEN); memcpy(pm_nicks[0], tmp, NICK_LEN); return; } } /* Insert at front */ if (pm_nick_count < MAX_PM_NICKS) pm_nick_count++; memmove(pm_nicks[1], pm_nicks[0], (pm_nick_count - 1) * NICK_LEN); snprintf(pm_nicks[0], NICK_LEN, "%s", n); } /* AI translation config */ static struct { char type[32]; /* ollama, vllm, openai */ char host[256]; /* host:port */ int port; char key[256]; char model[128]; char target_lang[64]; char skip_langs[256]; int enabled; } ai_cfg; /* Pending translation pipe fds */ #define MAX_TRANSLATE 8 static struct { int fd; /* read end of pipe from child */ int level; /* window to display in */ pid_t pid; char target[128]; /* channel/nick to echo translation to */ char original[512]; /* original text for similarity check */ } translate_pending[MAX_TRANSLATE]; static int translate_count = 0; static void ai_config_load(void) { memset(&ai_cfg, 0, sizeof(ai_cfg)); char path[512]; snprintf(path, sizeof(path), "%s/.hircrc", getenv("HOME")); FILE *f = fopen(path, "r"); if (!f) { /* Create default config */ f = fopen(path, "w"); if (f) { fprintf(f, "# AI translation settings\n"); fprintf(f, "ai_type=\n"); fprintf(f, "ai_host=\n"); fprintf(f, "ai_key=\n"); fprintf(f, "ai_model=\n"); fprintf(f, "ai_target_lang=\n"); fprintf(f, "ai_skip_langs=\n"); fprintf(f, "translate=on\n"); fprintf(f, "irc_colors=1\n"); fclose(f); } return; } char line[512]; while (fgets(line, sizeof(line), f)) { line[strcspn(line, "\r\n")] = '\0'; if (line[0] == '#' || line[0] == '\0') continue; char *eq = strchr(line, '='); if (!eq) continue; *eq++ = '\0'; if (strcmp(line, "ai_type") == 0) snprintf(ai_cfg.type, sizeof(ai_cfg.type), "%s", eq); else if (strcmp(line, "ai_host") == 0) { snprintf(ai_cfg.host, sizeof(ai_cfg.host), "%s", eq); char *colon = strrchr(ai_cfg.host, ':'); if (colon) { *colon = '\0'; ai_cfg.port = atoi(colon + 1); } } else if (strcmp(line, "ai_port") == 0) ai_cfg.port = atoi(eq); else if (strcmp(line, "ai_key") == 0) snprintf(ai_cfg.key, sizeof(ai_cfg.key), "%s", eq); else if (strcmp(line, "ai_model") == 0) snprintf(ai_cfg.model, sizeof(ai_cfg.model), "%s", eq); else if (strcmp(line, "ai_target_lang") == 0) snprintf(ai_cfg.target_lang, sizeof(ai_cfg.target_lang), "%s", eq); else if (strcmp(line, "ai_skip_langs") == 0) snprintf(ai_cfg.skip_langs, sizeof(ai_cfg.skip_langs), "%s", eq); else if (strcmp(line, "translate") == 0) { if (strcmp(eq, "off") == 0) { translate_enabled = 0; translate_public = 0; } else if (strcmp(eq, "public") == 0) { translate_enabled = 1; translate_public = 1; } else { translate_enabled = 1; translate_public = 0; } } else if (strcmp(line, "irc_colors") == 0) irc_colors = atoi(eq); else if (strcmp(line, "rent") == 0) snprintf(rent_msg, sizeof(rent_msg), "%s", eq); } fclose(f); /* Enable only if minimum config is present */ if (ai_cfg.host[0] && ai_cfg.port && ai_cfg.model[0] && ai_cfg.target_lang[0]) ai_cfg.enabled = 1; } static void config_save(void) { char path[512]; snprintf(path, sizeof(path), "%s/.hircrc", getenv("HOME")); FILE *f = fopen(path, "w"); if (!f) return; fprintf(f, "# AI translation settings\n"); fprintf(f, "ai_type=%s\n", ai_cfg.type); if (ai_cfg.port) fprintf(f, "ai_host=%s:%d\n", ai_cfg.host, ai_cfg.port); else fprintf(f, "ai_host=%s\n", ai_cfg.host); fprintf(f, "ai_key=%s\n", ai_cfg.key); fprintf(f, "ai_model=%s\n", ai_cfg.model); fprintf(f, "ai_target_lang=%s\n", ai_cfg.target_lang); fprintf(f, "ai_skip_langs=%s\n", ai_cfg.skip_langs); fprintf(f, "translate=%s\n", !translate_enabled ? "off" : translate_public ? "public" : "on"); fprintf(f, "irc_colors=%d\n", irc_colors); fclose(f); } /* Base64 decode: returns decoded length, or -1 if not valid base64 */ static int base64_decode(const char *in, size_t in_len, char *out, size_t out_size) { static const int T[256] = { ['A']=0,['B']=1,['C']=2,['D']=3,['E']=4,['F']=5,['G']=6,['H']=7, ['I']=8,['J']=9,['K']=10,['L']=11,['M']=12,['N']=13,['O']=14,['P']=15, ['Q']=16,['R']=17,['S']=18,['T']=19,['U']=20,['V']=21,['W']=22,['X']=23, ['Y']=24,['Z']=25,['a']=26,['b']=27,['c']=28,['d']=29,['e']=30,['f']=31, ['g']=32,['h']=33,['i']=34,['j']=35,['k']=36,['l']=37,['m']=38,['n']=39, ['o']=40,['p']=41,['q']=42,['r']=43,['s']=44,['t']=45,['u']=46,['v']=47, ['w']=48,['x']=49,['y']=50,['z']=51,['0']=52,['1']=53,['2']=54,['3']=55, ['4']=56,['5']=57,['6']=58,['7']=59,['8']=60,['9']=61,['+']=62,['/']=63, }; /* Strip trailing padding for length calc */ size_t pad = 0; while (in_len > 0 && in[in_len - 1] == '=') { pad++; in_len--; } if (in_len < 4) return -1; size_t out_len = in_len * 3 / 4; if (out_len >= out_size) return -1; size_t o = 0; for (size_t i = 0; i < in_len; i += 4) { int n = (int)(in_len - i); if (n < 2) break; unsigned int a = T[(unsigned char)in[i]]; unsigned int b = T[(unsigned char)in[i+1]]; out[o++] = (a << 2) | (b >> 4); if (n > 2 && i + 2 < in_len + pad) { unsigned int c = (i+2 < in_len) ? T[(unsigned char)in[i+2]] : 0; out[o++] = (b << 4) | (c >> 2); if (n > 3 && i + 3 < in_len + pad) { unsigned int d = (i+3 < in_len) ? T[(unsigned char)in[i+3]] : 0; out[o++] = (c << 6) | d; } } } /* Adjust for padding */ if (pad == 1 && o > 0) o--; if (pad == 2 && o > 0) o--; out[o] = '\0'; return (int)o; } /* Check if text looks like base64 and decode it. Returns 1 if decoded. */ static int try_base64_decode(const char *text, char *out, size_t out_size) { size_t len = strlen(text); if (len < 8 || len > 1000) return 0; /* Must be valid base64 chars only */ size_t i; for (i = 0; i < len; i++) { char c = text[i]; if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=')) return 0; } /* Length must be multiple of 4 */ if (len % 4 != 0) return 0; int n = base64_decode(text, len, out, out_size); if (n < 4) return 0; /* Check that result looks like text (mostly printable) */ int printable = 0; for (int j = 0; j < n; j++) { unsigned char c = (unsigned char)out[j]; if ((c >= 0x20 && c <= 0x7e) || c == '\n' || c == '\r' || c == '\t' || c >= 0x80) printable++; } if (printable * 100 / n < 80) return 0; return 1; } static int needs_translation(const char *text) { if (!ai_cfg.enabled || !translate_enabled) return 0; /* Skip very short messages */ int words = 0; for (const char *p = text; *p; p++) if (*p == ' ') words++; if (words < 1 && strlen(text) < 6) return 0; /* Skip messages that are just URLs */ if (strncmp(text, "http://", 7) == 0 || strncmp(text, "https://", 8) == 0) { /* If no space after URL, it's just a link */ const char *sp = strchr(text, ' '); if (!sp) return 0; } /* Pre-filter: if text contains common words from skip languages, skip */ static const struct { const char *lang; const char *words[33]; } lang_words[] = { {"swedish", {"jag", "och", "att", "det", "inte", "var", "som", "med", "har", "den", "kan", "ska", "till", "eller", "men", "ett", "en", "ta", "vad", "hur", "dig", "du", "vi", "de", "sig", "hade", "sedan", "bara", "\xc3\xa4r", "\xc3\xa5", "f\xc3\xb6r", "p\xc3\xa5", NULL}}, {"english", {"the", "and", "that", "this", "with", "have", "was", "are", "you", "not", "from", "but", "for", "can", NULL}}, {NULL, {NULL}} }; char lower[1024]; snprintf(lower, sizeof(lower), "%s", text); for (char *p = lower; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; int hits = 0; for (int i = 0; lang_words[i].lang; i++) { if (!strstr(ai_cfg.skip_langs, lang_words[i].lang)) continue; for (int j = 0; lang_words[i].words[j]; j++) { const char *w = lang_words[i].words[j]; size_t wlen = strlen(w); char *p = lower; while ((p = strstr(p, w)) != NULL) { /* Check word boundaries (non-alpha on both sides) */ int before_ok = (p == lower) || !(*(p-1) >= 'a' && *(p-1) <= 'z'); int after_ok = !p[wlen] || !(p[wlen] >= 'a' && p[wlen] <= 'z'); if (before_ok && after_ok) { hits++; break; } p++; } } } int threshold = (words < 4) ? 1 : 2; if (hits >= threshold) return 0; return 1; } static void translate_async(const char *text, int level, const char *target) { if (translate_count >= MAX_TRANSLATE) return; int pipefd[2]; if (pipe(pipefd) < 0) return; pid_t pid = fork(); if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return; } if (pid == 0) { /* Child: do HTTP request to AI API */ close(pipefd[0]); int s = socket(AF_INET, SOCK_STREAM, 0); if (s < 0) _exit(1); struct hostent *he = gethostbyname(ai_cfg.host); if (!he) _exit(1); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_port = htons(ai_cfg.port); memcpy(&addr.sin_addr, he->h_addr, he->h_length); if (connect(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) _exit(1); /* Build JSON body */ char escaped[1024]; int ei = 0; for (int i = 0; text[i] && ei < (int)sizeof(escaped) - 2; i++) { if (text[i] == '"' || text[i] == '\\') escaped[ei++] = '\\'; if (text[i] == '\n') { escaped[ei++] = '\\'; escaped[ei++] = 'n'; } else escaped[ei++] = text[i]; } escaped[ei] = '\0'; char body[2048]; snprintf(body, sizeof(body), "{\"model\":\"%s\",\"messages\":[" "{\"role\":\"system\",\"content\":\"Translate the message to both %s and %s. Format: %s translation|||%s translation. Nothing else.\"}," "{\"role\":\"user\",\"content\":\"%s\"}" "],\"stream\":false,\"tool_choice\":\"none\"}", ai_cfg.model, ai_cfg.target_lang, ai_cfg.skip_langs, ai_cfg.target_lang, ai_cfg.skip_langs, escaped); char req[4096]; int rlen = snprintf(req, sizeof(req), "POST /v1/chat/completions HTTP/1.1\r\n" "Host: %s:%d\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n" "%s%s%s" "Connection: close\r\n\r\n%s", ai_cfg.host, ai_cfg.port, (int)strlen(body), ai_cfg.key[0] ? "Authorization: Bearer " : "", ai_cfg.key[0] ? ai_cfg.key : "", ai_cfg.key[0] ? "\r\n" : "", body); if (write(s, req, rlen) < 0) _exit(1); /* Read response */ char resp[8192]; int total = 0; int n; while ((n = read(s, resp + total, sizeof(resp) - total - 1)) > 0) total += n; resp[total] = '\0'; close(s); /* Extract content from JSON response */ char *content = strstr(resp, "\"content\":"); if (content) { content += 10; while (*content == ' ' || *content == '\t') content++; if (*content == '"') { content++; char result[1024]; int ri = 0; while (*content && *content != '"' && ri < (int)sizeof(result) - 1) { if (*content == '\\' && content[1]) { content++; if (*content == 'n') result[ri++] = ' '; else result[ri++] = *content; } else { result[ri++] = *content; } content++; } result[ri] = '\0'; (void)!write(pipefd[1], result, ri); } } close(pipefd[1]); _exit(0); } /* Parent */ close(pipefd[1]); translate_pending[translate_count].fd = pipefd[0]; translate_pending[translate_count].level = level; translate_pending[translate_count].pid = pid; snprintf(translate_pending[translate_count].target, sizeof(translate_pending[translate_count].target), "%s", target ? target : ""); snprintf(translate_pending[translate_count].original, sizeof(translate_pending[translate_count].original), "%s", text); translate_count++; } /* Get the active channel for current window, or "" */ static const char *current_channel(void) { if (current_level >= WL_CHAN && current_level < WL_MAX) { int idx = current_level - WL_CHAN; if (idx < MAX_CHAN_WINS) return win_chans[idx].name; } return ""; } static int get_term_size(void) { struct winsize ws; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) { term_rows = ws.ws_row; term_cols = ws.ws_col; return 0; } return -1; } static void draw_statusbar(void) { get_term_size(); char bar[512]; const char *chan = ""; char prefix_str[4] = ""; const char *cmodes = ""; if (current_level >= WL_CHAN && current_level < WL_MAX) { int idx = current_level - WL_CHAN; chan = win_chans[idx].name; cmodes = win_chans[idx].modes; if (win_chans[idx].my_prefix) snprintf(prefix_str, sizeof(prefix_str), "%c", win_chans[idx].my_prefix); } else { chan = query_target[0] ? query_target : "(status)"; } snprintf(bar, sizeof(bar), " [%d:%s] %s%s %s%s%s", current_level + 1, current_level == WL_STATUS ? "status" : chan, prefix_str, nick, cmodes[0] ? "[" : "", cmodes, cmodes[0] ? "]" : ""); /* Append activity indicator */ char act[64] = ""; int apos = 0; for (int i = 0; i < WL_MAX; i++) { if (win_activity[i] && i != current_level) { if (apos == 0) apos += snprintf(act + apos, sizeof(act) - apos, " (Act: "); else apos += snprintf(act + apos, sizeof(act) - apos, ","); apos += snprintf(act + apos, sizeof(act) - apos, "%d", i + 1); } } if (apos > 0) snprintf(act + apos, sizeof(act) - apos, ")"); size_t blen = strlen(bar); snprintf(bar + blen, sizeof(bar) - blen, "%s", act); /* Right-align timestamp */ time_t now = time(NULL); struct tm *tm = localtime(&now); char rhs[64]; snprintf(rhs, sizeof(rhs), "[Holck Mirk - %02d%02d %02d:%02d]", tm->tm_mon + 1, tm->tm_mday, tm->tm_hour, tm->tm_min); size_t bar_len = strlen(bar); size_t rhs_len = strlen(rhs); if (bar_len + rhs_len + 1 < (size_t)term_cols) { size_t pad = term_cols - rhs_len - 1; while (bar_len < pad) bar[bar_len++] = ' '; memcpy(bar + pad, rhs, rhs_len); bar[pad + rhs_len] = ' '; bar[term_cols] = '\0'; } /* Status bar on second-to-last row */ printf("\033[%d;1H\033[7m%-*.*s\033[0m", term_rows - 1, term_cols, term_cols, bar); /* Move cursor to input line */ printf("\033[%d;1H", term_rows); fflush(stdout); } /* Store a line in a window's scrollback */ /* mIRC colour code to ANSI */ static const char *mirc_to_ansi[] = { "\033[97m", /* 0: white */ "\033[30m", /* 1: black */ "\033[34m", /* 2: blue */ "\033[32m", /* 3: green */ "\033[31m", /* 4: red */ "\033[33m", /* 5: brown */ "\033[35m", /* 6: purple */ "\033[33m", /* 7: orange */ "\033[93m", /* 8: yellow */ "\033[92m", /* 9: light green */ "\033[36m", /* 10: cyan */ "\033[96m", /* 11: light cyan */ "\033[94m", /* 12: light blue */ "\033[95m", /* 13: pink */ "\033[90m", /* 14: grey */ "\033[37m", /* 15: light grey */ }; static size_t irc_color_process(char *out, size_t outsize, const char *in) { size_t o = 0; for (const char *p = in; *p && o < outsize - 10; p++) { if (*p == '\x03') { /* mIRC colour: ^C[fg[,bg]] */ p++; if (irc_colors && *p >= '0' && *p <= '9') { int fg = *p - '0'; p++; if (*p >= '0' && *p <= '9') { fg = fg * 10 + (*p - '0'); p++; } if (*p == ',') { p++; if (*p >= '0' && *p <= '9') p++; if (*p >= '0' && *p <= '9') p++; } if (fg < 16) { const char *a = mirc_to_ansi[fg]; size_t alen = strlen(a); if (o + alen < outsize) { memcpy(out + o, a, alen); o += alen; } } } else if (!irc_colors) { /* Strip: skip digits and comma */ if (*p >= '0' && *p <= '9') p++; if (*p >= '0' && *p <= '9') p++; if (*p == ',') { p++; if (*p >= '0' && *p <= '9') p++; if (*p >= '0' && *p <= '9') p++; } } p--; /* loop will p++ */ } else if (*p == '\x02') { /* Bold */ if (irc_colors) { const char *a = "\033[1m"; memcpy(out + o, a, 4); o += 4; } } else if (*p == '\x1F') { /* Underline */ if (irc_colors) { const char *a = "\033[4m"; memcpy(out + o, a, 4); o += 4; } } else if (*p == '\x0F') { /* Reset */ if (irc_colors) { const char *a = "\033[0m"; memcpy(out + o, a, 4); o += 4; } } else if (*p == '\x1D') { /* Italic */ if (irc_colors) { const char *a = "\033[3m"; memcpy(out + o, a, 4); o += 4; } } else { out[o++] = *p; } } /* Reset at end if colours were used */ if (irc_colors && o > 0) { const char *a = "\033[0m"; size_t alen = strlen(a); if (o + alen < outsize) { memcpy(out + o, a, alen); o += alen; } } out[o] = '\0'; return o; } static void buf_store(int level, const char *line){ if (level < 0 || level >= WL_MAX) return; char processed[512]; irc_color_process(processed, sizeof(processed), line); snprintf(win_buf[level].lines[win_buf[level].head], 512, "%s", processed); win_buf[level].head = (win_buf[level].head + 1) % SCROLLBACK; if (win_buf[level].count < SCROLLBACK) win_buf[level].count++; } /* Redraw the current window's scrollback */ static void redraw_window(void) { get_term_size(); printf("\033[1;%dr", term_rows - 2); /* ensure scroll region is set */ int visible = term_rows - 2; int count = win_buf[current_level].count; /* Clamp scroll offset */ int max_scroll = count - visible; if (max_scroll < 0) max_scroll = 0; if (scroll_offset > max_scroll) scroll_offset = max_scroll; /* Clear scroll region */ for (int i = 1; i <= visible; i++) printf("\033[%d;1H\033[K", i); /* Figure out how many lines fit, accounting for wrapping */ int end_pos = count - scroll_offset; int rows_used = 0; int start_pos = end_pos; while (start_pos > 0 && rows_used < visible) { int li = start_pos - 1; int buf_idx; if (count <= SCROLLBACK) buf_idx = li; else buf_idx = (win_buf[current_level].head - count + li + SCROLLBACK) % SCROLLBACK; int line_cols = (int)display_cols(win_buf[current_level].lines[buf_idx], strlen(win_buf[current_level].lines[buf_idx])); int line_rows = line_cols / term_cols + 1; if (rows_used + line_rows > visible) break; rows_used += line_rows; start_pos--; } int show = end_pos - start_pos; /* Print lines from top, letting terminal wrap naturally */ int row = visible - rows_used + 1; printf("\033[%d;1H", row); for (int i = 0; i < show; i++) { int li = start_pos + i; int buf_idx; if (count <= SCROLLBACK) buf_idx = li; else buf_idx = (win_buf[current_level].head - count + li + SCROLLBACK) % SCROLLBACK; printf("\033[K%s\n", win_buf[current_level].lines[buf_idx]); } draw_statusbar(); } static void wprintf(int level, const char *fmt, ...) { va_list ap; char msg[512]; char timestamped[512]; time_t now = time(NULL); struct tm *tm = localtime(&now); va_start(ap, fmt); vsnprintf(msg, sizeof(msg), fmt, ap); va_end(ap); /* Add timestamp */ snprintf(timestamped, sizeof(timestamped), "%02d:%02d %.500s", tm->tm_hour, tm->tm_min, msg); /* Remove trailing newline for storage */ size_t len = strlen(timestamped); if (len > 0 && timestamped[len-1] == '\n') timestamped[len-1] = '\0'; buf_store(level, timestamped); /* Only display if this is the current window */ if (level == current_level) { if (scroll_offset == 0) { printf("\033[%d;1H\n\033[K%s", term_rows - 2, timestamped); draw_statusbar(); } } else { /* Mark activity on other window */ win_activity[level] = 1; draw_statusbar(); } } static void die(const char *msg) { perror(msg); exit(1); } static void sock_write(const void *buf, size_t len) { ssize_t n = write(sock_fd, buf, len); (void)n; } static void irc_send_raw(const char *fmt, ...) { char buf[IRC_MAX]; va_list ap; va_start(ap, fmt); vsnprintf(buf, sizeof(buf) - 2, fmt, ap); va_end(ap); size_t len = strlen(buf); buf[len] = '\r'; buf[len + 1] = '\n'; len += 2; sock_write(buf, len); } static void irc_send_converted(const char *prefix, const unsigned char *text, size_t text_len) { char converted[IRC_MAX]; char line[IRC_MAX]; to_iso8859_1(text, text_len, converted, sizeof(converted) / 2); size_t len = (size_t)snprintf(line, sizeof(line) - 2, "%s%.200s", prefix, converted); if (len > sizeof(line) - 3) len = sizeof(line) - 3; line[len] = '\r'; line[len + 1] = '\n'; len += 2; sock_write(line, len); } static int irc_connect(const char *host, const char *port) { struct addrinfo hints, *res, *p; int fd = -1; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host, port, &hints, &res) != 0) die("getaddrinfo"); for (p = res; p; p = p->ai_next) { fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); if (fd < 0) continue; if (connect(fd, p->ai_addr, p->ai_addrlen) == 0) break; close(fd); fd = -1; } freeaddrinfo(res); if (fd < 0) die("connect"); return fd; } /* Find window index for a channel, or -1 */ /* Nick list management */ static void nicklist_add(int idx, const char *n) { if (idx < 0 || idx >= MAX_CHAN_WINS) return; /* Skip prefix chars */ if (*n == '@' || *n == '+' || *n == '%') n++; if (!*n) return; /* Check if already present */ for (int i = 0; i < win_chans[idx].nick_count; i++) if (strcasecmp(win_chans[idx].nicks[i], n) == 0) return; if (win_chans[idx].nick_count < MAX_NICKS) snprintf(win_chans[idx].nicks[win_chans[idx].nick_count++], NICK_LEN, "%s", n); } static int nicklist_remove(int idx, const char *n) { if (idx < 0 || idx >= MAX_CHAN_WINS) return 0; for (int i = 0; i < win_chans[idx].nick_count; i++) { if (strcasecmp(win_chans[idx].nicks[i], n) == 0) { win_chans[idx].nick_count--; if (i < win_chans[idx].nick_count) memcpy(win_chans[idx].nicks[i], win_chans[idx].nicks[win_chans[idx].nick_count], NICK_LEN); return 1; } } return 0; } static int chan_win_idx(const char *chan) { int i; for (i = 0; i < MAX_CHAN_WINS; i++) { if (strcasecmp(win_chans[i].name, chan) == 0) return i; } return -1; } /* Get level for a channel (find existing or assign to current window) */ static int chan_to_level(const char *chan) { int idx = chan_win_idx(chan); if (idx >= 0) return WL_CHAN + idx; /* Assign to current window if it's a channel window and empty */ if (current_level >= WL_CHAN) { idx = current_level - WL_CHAN; if (win_chans[idx].name[0] == '\0') { snprintf(win_chans[idx].name, sizeof(win_chans[idx].name), "%s", chan); return current_level; } } /* Find first empty slot */ int i; for (i = 0; i < MAX_CHAN_WINS; i++) { if (win_chans[i].name[0] == '\0') { snprintf(win_chans[i].name, sizeof(win_chans[i].name), "%s", chan); return WL_CHAN + i; } } return WL_CHAN; } /* Update our prefix for a channel based on MODE changes */ static void update_my_prefix(const char *chan, const char *modestr, const char *args) { int idx = chan_win_idx(chan); if (idx < 0) return; int adding = 1; const char *m = modestr; /* Simple parse: walk mode chars, consume args for o/v */ char arg_buf[512]; snprintf(arg_buf, sizeof(arg_buf), "%s", args ? args : ""); char *arg = arg_buf; while (*m) { if (*m == '+') { adding = 1; m++; continue; } if (*m == '-') { adding = 0; m++; continue; } /* Modes that take a parameter */ char *this_arg = NULL; if (*m == 'o' || *m == 'v' || *m == 'b' || *m == 'k' || *m == 'l') { this_arg = arg; char *sp = strchr(arg, ' '); if (sp) { *sp = '\0'; arg = sp + 1; } else arg = arg + strlen(arg); } if ((*m == 'o' || *m == 'v') && this_arg && strcasecmp(this_arg, nick) == 0) { if (*m == 'o') win_chans[idx].my_prefix = adding ? '@' : '\0'; else if (*m == 'v' && win_chans[idx].my_prefix != '@') win_chans[idx].my_prefix = adding ? '+' : '\0'; } /* Track channel modes (non-user modes) */ if (*m != 'o' && *m != 'v' && *m != 'b') { char *cm = win_chans[idx].modes; size_t clen = strlen(cm); if (adding) { /* Add if not present */ if (!strchr(cm, *m) && clen + 1 < sizeof(win_chans[idx].modes)) { if (clen == 0) { cm[0] = '+'; cm[1] = *m; cm[2] = '\0'; } else { cm[clen] = *m; cm[clen+1] = '\0'; } } } else { /* Remove */ char *p = strchr(cm, *m); if (p) memmove(p, p + 1, strlen(p)); /* Remove '+' if empty */ if (strcmp(cm, "+") == 0) cm[0] = '\0'; } } m++; } } static FILE *logfp = NULL; static void log_raw(const char *line, size_t len) { if (!logfp) return; time_t now = time(NULL); struct tm *tm = localtime(&now); fprintf(logfp, "[%02d:%02d:%02d] len=%zu charset=", tm->tm_hour, tm->tm_min, tm->tm_sec, len); /* Detect charset */ const unsigned char *u = (const unsigned char *)line; int has_high = 0, valid_utf8 = 1; for (size_t i = 0; i < len; i++) { if (u[i] >= 0x80) { has_high = 1; if ((u[i] & 0xE0) == 0xC0) { if (i+1 >= len || (u[i+1] & 0xC0) != 0x80) valid_utf8 = 0; else i += 1; } else if ((u[i] & 0xF0) == 0xE0) { if (i+2 >= len || (u[i+1] & 0xC0) != 0x80 || (u[i+2] & 0xC0) != 0x80) valid_utf8 = 0; else i += 2; } else if ((u[i] & 0xF8) == 0xF0) { if (i+3 >= len || (u[i+1] & 0xC0) != 0x80 || (u[i+2] & 0xC0) != 0x80 || (u[i+3] & 0xC0) != 0x80) valid_utf8 = 0; else i += 3; } else { valid_utf8 = 0; } } } if (!has_high) fprintf(logfp, "ASCII"); else if (valid_utf8) fprintf(logfp, "UTF-8"); else fprintf(logfp, "ISO-8859-1"); fprintf(logfp, "\n text: %s\n hex: ", line); for (size_t i = 0; i < len; i++) fprintf(logfp, "%02X ", u[i]); fprintf(logfp, "\n"); fflush(logfp); } static void handle_line(char *line) { size_t rawlen = strlen(line); log_raw(line, rawlen); /* Incoming text is displayed as-is (terminal is UTF-8). * No conversion needed for display. */ char *converted = line; if (strncmp(converted, "PING ", 5) == 0) { irc_send_raw("PONG %s", converted + 5); return; } char *prefix = NULL; char *cmd = converted; if (cmd[0] == ':') { prefix = cmd + 1; cmd = strchr(cmd, ' '); if (!cmd) return; *cmd++ = '\0'; } while (*cmd == ' ') cmd++; char sender[64] = ""; if (prefix) { char *bang = strchr(prefix, '!'); if (bang) { size_t nlen = (size_t)(bang - prefix); if (nlen >= sizeof(sender)) nlen = sizeof(sender) - 1; memcpy(sender, prefix, nlen); sender[nlen] = '\0'; } else { snprintf(sender, sizeof(sender), "%.63s", prefix); } } char *params = strchr(cmd, ' '); if (params) *params++ = '\0'; if (strcmp(cmd, "PRIVMSG") == 0 && params) { char *target = params; char *text = strchr(params, ':'); if (text) { char *sp = strchr(target, ' '); if (sp) *sp = '\0'; text++; if (text[0] == '\x01') { if (strncmp(text, "\x01VERSION\x01", 9) == 0) { struct utsname ut; uname(&ut); irc_send_raw("NOTICE %s :\x01VERSION " "Holck's Mirk, OS: %s %s %s" " :: %s\x01", sender, ut.sysname, ut.release, ut.machine, rent_msg); wprintf(WL_STATUS, "* CTCP VERSION from %s\n", sender); } else if (strncmp(text, "\x01" "ACTION ", 8) == 0) { /* /me action */ char *action = text + 8; char *end = strchr(action, '\x01'); if (end) *end = '\0'; if (strcasecmp(target, nick) == 0) { wprintf(WL_MSG, "* %s %s\n", sender, action); } else { int lvl = chan_to_level(target); wprintf(lvl, "* %s %s\n", sender, action); } } else { wprintf(WL_STATUS, "* CTCP from %s: %s\n", sender, text + 1); } } else if (strcasecmp(target, nick) == 0) { wprintf(WL_MSG, "<%s> %s\n", sender, text); pm_nick_add(sender); char b64dec[1024]; if (try_base64_decode(text, b64dec, sizeof(b64dec))) { if (needs_translation(b64dec)) translate_async(b64dec, WL_MSG, NULL); else wprintf(WL_MSG, " \033[3m[b64: %s]\033[0m\n", b64dec); } else if (needs_translation(text)) translate_async(text, WL_MSG, NULL); } else { int lvl = chan_to_level(target); wprintf(lvl, "<%s> %s\n", sender, text); char b64dec2[1024]; if (try_base64_decode(text, b64dec2, sizeof(b64dec2))) { if (needs_translation(b64dec2)) translate_async(b64dec2, lvl, translate_public ? target : NULL); else wprintf(lvl, " \033[3m[b64: %s]\033[0m\n", b64dec2); } else if (needs_translation(text)) translate_async(text, lvl, translate_public ? target : NULL); } } } else if (strcmp(cmd, "NOTICE") == 0 && params) { char *text = strchr(params, ':'); if (text) text++; else text = params; wprintf(WL_STATUS, "-%s- %s\n", sender[0] ? sender : "*", text); } else if (strcmp(cmd, "JOIN") == 0) { char *chan = params; if (chan && chan[0] == ':') chan++; int lvl = chan ? chan_to_level(chan) : WL_STATUS; if (chan) nicklist_add(chan_win_idx(chan), sender); wprintf(lvl, "\033[1m* %s has joined %s\033[0m\n", sender, chan ? chan : ""); draw_statusbar(); } else if (strcmp(cmd, "PART") == 0) { char *chan = params; char *reason = NULL; if (chan) { reason = strchr(chan, ':'); if (reason) { char *sp = strchr(chan, ' '); if (sp) *sp = '\0'; reason++; } } int lvl = chan ? chan_to_level(chan) : WL_STATUS; if (chan) nicklist_remove(chan_win_idx(chan), sender); wprintf(lvl, "\033[1m* %s has left %s (%s)\033[0m\n", sender, chan ? chan : "", reason ? reason : ""); /* If we parted, clear the window */ if (strcasecmp(sender, nick) == 0 && chan) { int idx = chan_win_idx(chan); if (idx >= 0) { win_chans[idx].name[0] = '\0'; win_chans[idx].modes[0] = '\0'; win_chans[idx].my_prefix = '\0'; win_chans[idx].nick_count = 0; draw_statusbar(); } } } else if (strcmp(cmd, "QUIT") == 0) { char *reason = params; if (reason && reason[0] == ':') reason++; /* Show quit only in channels where the user was present */ for (int i = 0; i < MAX_CHAN_WINS; i++) { if (win_chans[i].name[0]) { if (nicklist_remove(i, sender)) wprintf(WL_CHAN + i, "\033[1m* %s has quit (%s)\033[0m\n", sender, reason ? reason : ""); } } } else if (strcmp(cmd, "NICK") == 0) { char *newnick = params; if (newnick && newnick[0] == ':') newnick++; /* Update nick lists and show only in channels where user is present */ for (int i = 0; i < MAX_CHAN_WINS; i++) { if (win_chans[i].name[0]) { int found = 0; for (int j = 0; j < win_chans[i].nick_count; j++) { if (strcasecmp(win_chans[i].nicks[j], sender) == 0) { if (newnick) snprintf(win_chans[i].nicks[j], NICK_LEN, "%s", newnick); found = 1; break; } } if (found) wprintf(WL_CHAN + i, "* %s is now known as %s\n", sender, newnick ? newnick : ""); } } if (strcasecmp(sender, nick) == 0 && newnick) { snprintf(nick, sizeof(nick), "%s", newnick); draw_statusbar(); } } else if (strcmp(cmd, "MODE") == 0 && params) { /* MODE #chan +modes [args] */ char *target = params; char *modestr = strchr(params, ' '); if (modestr) { *modestr++ = '\0'; char *modeargs = strchr(modestr, ' '); if (modeargs) *modeargs++ = '\0'; if (target[0] == '#' || target[0] == '&') { update_my_prefix(target, modestr, modeargs); int lvl = chan_to_level(target); wprintf(lvl, "\033[1m* %s sets mode %s %s\033[0m\n", sender[0] ? sender : "*", modestr, modeargs ? modeargs : ""); draw_statusbar(); } else { wprintf(WL_STATUS, "\033[1m* %s sets mode %s\033[0m\n", sender[0] ? sender : "*", modestr); } } } else if (strcmp(cmd, "324") == 0 && params) { /* RPL_CHANNELMODEIS: */ char *p = params; /* skip our nick */ char *sp = strchr(p, ' '); if (sp) { p = sp + 1; char *chan = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; char *modes = sp + 1; /* Strip trailing params */ sp = strchr(modes, ' '); if (sp) *sp = '\0'; int idx = chan_win_idx(chan); if (idx >= 0) { snprintf(win_chans[idx].modes, sizeof(win_chans[idx].modes), "%s", modes); draw_statusbar(); } } } } else if (strcmp(cmd, "311") == 0 && params) { /* RPL_WHOISUSER: * : */ char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; /* skip our nick */ char *wnick = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; p = sp + 1; } char *wuser = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; p = sp + 1; } char *whost = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; p = sp + 1; } /* skip the * */ sp = strchr(p, ':'); char *real = sp ? sp + 1 : p; wprintf(WL_STATUS, "*** %s is %s@%s (%s)\n", wnick, wuser, whost, real); } else if (strcmp(cmd, "319") == 0 && params) { /* RPL_WHOISCHANNELS: : */ char *chans = strchr(params, ':'); if (chans) chans++; char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; char *wnick = p; sp = strchr(p, ' '); if (sp) *sp = '\0'; wprintf(WL_STATUS, "*** %s on channels: %s\n", wnick, chans ? chans : ""); } else if (strcmp(cmd, "312") == 0 && params) { /* RPL_WHOISSERVER: : */ char *info = strchr(params, ':'); if (info) info++; char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; char *wnick = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; p = sp + 1; } char *server = p; sp = strchr(p, ' '); if (sp) *sp = '\0'; wprintf(WL_STATUS, "*** %s on irc via server %s (%s)\n", wnick, server, info ? info : ""); } else if (strcmp(cmd, "317") == 0 && params) { /* RPL_WHOISIDLE: : */ char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; char *wnick = p; sp = strchr(p, ' '); if (sp) { *sp = '\0'; p = sp + 1; } int idle_secs = atoi(p); int mins = idle_secs / 60; int secs = idle_secs % 60; if (mins > 0) wprintf(WL_STATUS, "*** %s has been idle %d min %d sec\n", wnick, mins, secs); else wprintf(WL_STATUS, "*** %s has been idle %d sec\n", wnick, secs); } else if (strcmp(cmd, "320") == 0 && params) { /* RPL_WHOISSPECIAL: : */ char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; char *wnick = p; sp = strchr(p, ' '); if (sp) *sp = '\0'; char *text = strchr(params, ':'); if (text) text++; wprintf(WL_STATUS, "*** %s %s\n", wnick, text ? text : ""); } else if (strcmp(cmd, "313") == 0 && params) { /* RPL_WHOISOPERATOR: : */ char *text = strchr(params, ':'); if (text) text++; char *p = params; char *sp = strchr(p, ' '); if (sp) p = sp + 1; char *wnick = p; sp = strchr(p, ' '); if (sp) *sp = '\0'; wprintf(WL_STATUS, "*** %s %s\n", wnick, text ? text : "is an IRC Operator"); } else if (strcmp(cmd, "318") == 0 && params) { /* RPL_ENDOFWHOIS — suppress or show subtle */ wprintf(WL_STATUS, "*** End of WHOIS\n"); } else if (strcmp(cmd, "332") == 0 && params) { /* RPL_TOPIC: : */ char *p = params; char *sp = strchr(p, ' '); if (sp) { p = sp + 1; /* skip our nick */ char *chan = p; char *topic = strchr(p, ':'); if (topic) { sp = strchr(chan, ' '); if (sp) *sp = '\0'; topic++; int lvl = chan_to_level(chan); wprintf(lvl, "* Topic for %s: %s\n", chan, topic); } } } else if (strcmp(cmd, "TOPIC") == 0 && params) { /* :nick TOPIC #channel :new topic */ char *chan = params; char *topic = strchr(params, ':'); if (topic) { char *sp = strchr(chan, ' '); if (sp) *sp = '\0'; topic++; int lvl = chan_to_level(chan); wprintf(lvl, "* %s changed topic to: %s\n", sender, topic); } } else if ((strcmp(cmd, "404") == 0 || strcmp(cmd, "482") == 0 || strcmp(cmd, "473") == 0 || strcmp(cmd, "474") == 0 || strcmp(cmd, "475") == 0) && params) { /* Channel error numerics: : */ char *p = params; char *sp = strchr(p, ' '); if (sp) { p = sp + 1; char *chan = p; char *text = strchr(p, ':'); if (text) { sp = strchr(chan, ' '); if (sp) *sp = '\0'; text++; int idx = chan_win_idx(chan); int lvl = idx >= 0 ? WL_CHAN + idx : WL_STATUS; wprintf(lvl, "* %s: %s\n", chan, text); } } } else if (strcmp(cmd, "353") == 0 && params) { /* RPL_NAMREPLY: = :names... */ char *colon = strchr(params, ':'); if (colon) { char *p = params; char *chan = NULL; char *sp; sp = strchr(p, ' '); if (sp) p = sp + 1; sp = strchr(p, ' '); if (sp) p = sp + 1; sp = strchr(p, ' '); if (sp) { *sp = '\0'; chan = p; } if (chan) { char *names = colon + 1; int lvl = chan_to_level(chan); wprintf(lvl, "[%s] %s\n", chan, names); int idx = chan_win_idx(chan); if (idx >= 0) { char namecopy[512]; snprintf(namecopy, sizeof(namecopy), "%s", names); char *tok = strtok(namecopy, " "); while (tok) { const char *n = tok; char pf = '\0'; if (*n == '@' || *n == '+') { pf = *n; n++; } nicklist_add(idx, n); if (strcasecmp(n, nick) == 0) { win_chans[idx].my_prefix = pf; draw_statusbar(); } tok = strtok(NULL, " "); } } } } } else { if (params) { char *text = strchr(params, ':'); if (text) text++; else text = params; wprintf(WL_STATUS, "[%s] %s\n", cmd, text); } else { wprintf(WL_STATUS, "[%s]\n", cmd); } } } static void process_recv(void) { char *crlf; while ((crlf = strstr(recv_buf, "\r\n")) != NULL) { *crlf = '\0'; handle_line(recv_buf); size_t line_len = (size_t)(crlf - recv_buf) + 2; recv_len -= line_len; memmove(recv_buf, crlf + 2, recv_len); recv_buf[recv_len] = '\0'; } } static void handle_input(char *line) { size_t len = strlen(line); if (len > 0 && line[len-1] == '\n') line[--len] = '\0'; if (len == 0) return; if (line[0] == '/') { char *cmd = line + 1; char *args = strchr(cmd, ' '); if (args) *args++ = '\0'; if (strcasecmp(cmd, "join") == 0 && args) { /* Assign channel to current window if it's a chan window */ chan_to_level(args); irc_send_raw("JOIN %s", args); irc_send_raw("MODE %s", args); } else if (strcasecmp(cmd, "part") == 0) { const char *chan = args ? args : current_channel(); if (chan[0]) irc_send_raw("PART %s", chan); } else if ((strcasecmp(cmd, "msg") == 0 || strcasecmp(cmd, "w") == 0) && args) { char *target = args; char *text = strchr(args, ' '); if (text) { *text++ = '\0'; char pfx[IRC_MAX]; snprintf(pfx, sizeof(pfx), "PRIVMSG %s :", target); irc_send_converted(pfx, (unsigned char *)text, strlen(text)); wprintf(WL_MSG, "-> %s: %s\n", target, text); /* Ensure target is in PM list (but don't reorder) */ int found = 0; for (int i = 0; i < pm_nick_count; i++) if (strcasecmp(pm_nicks[i], target) == 0) { found = 1; break; } if (!found && pm_nick_count < MAX_PM_NICKS) snprintf(pm_nicks[pm_nick_count++], NICK_LEN, "%s", target); } } else if (strcasecmp(cmd, "q") == 0) { if (args && args[0]) { snprintf(query_target, sizeof(query_target), "%s", args); wprintf(WL_MSG, "* Now talking to %s\n", query_target); draw_statusbar(); } else { if (query_target[0]) wprintf(WL_MSG, "* No longer talking to %s\n", query_target); query_target[0] = '\0'; draw_statusbar(); } } else if (strcasecmp(cmd, "nick") == 0 && args) { snprintf(nick, sizeof(nick), "%s", args); irc_send_raw("NICK %s", args); draw_statusbar(); } else if (strcasecmp(cmd, "me") == 0 && args) { const char *chan = current_channel(); if (chan[0]) { irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", chan, args); wprintf(current_level, "* %s %s\n", nick, args); } else if (query_target[0]) { irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", query_target, args); wprintf(WL_MSG, "* %s %s\n", nick, args); } } else if (strcasecmp(cmd, "slap") == 0 && args) { const char *chan = current_channel(); char slap[256]; snprintf(slap, sizeof(slap), "slaps %s around a bit with a large trout", args); if (chan[0]) { irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", chan, slap); wprintf(current_level, "* %s %s\n", nick, slap); } else if (query_target[0]) { irc_send_raw("PRIVMSG %s :\x01" "ACTION %s\x01", query_target, slap); wprintf(WL_MSG, "* %s %s\n", nick, slap); } } else if (strcasecmp(cmd, "trans") == 0) { if (!translate_enabled) { translate_enabled = 1; translate_public = 0; wprintf(current_level, "* Translation ON (local only)\n"); } else if (!translate_public) { translate_public = 1; wprintf(current_level, "* Translation ON (public echo)\n"); } else { translate_enabled = 0; translate_public = 0; wprintf(current_level, "* Translation OFF\n"); } config_save(); } else if (strcasecmp(cmd, "colors") == 0) { irc_colors = !irc_colors; wprintf(current_level, "* IRC colours %s\n", irc_colors ? "ON" : "OFF (stripped)"); config_save(); } else if (strcasecmp(cmd, "ctcp") == 0 && args) { char *target = args; char *ctcp_cmd = strchr(args, ' '); if (ctcp_cmd) { *ctcp_cmd++ = '\0'; /* Uppercase the CTCP command */ for (char *p = ctcp_cmd; *p && *p != ' '; p++) *p = (*p >= 'a' && *p <= 'z') ? *p - 32 : *p; irc_send_raw("PRIVMSG %s :\x01%s\x01", target, ctcp_cmd); wprintf(WL_STATUS, "* CTCP %s sent to %s\n", ctcp_cmd, target); } else { wprintf(current_level, "Usage: /ctcp \n"); } } else if (strcasecmp(cmd, "mode") == 0 && args) { /* Replace * with current channel */ if (args[0] == '*') { const char *chan = current_channel(); if (chan[0]) { char modebuf[IRC_MAX]; snprintf(modebuf, sizeof(modebuf), "%s%s", chan, args + 1); irc_send_raw("MODE %s", modebuf); } } else { irc_send_raw("MODE %s", args); } } else if (strcasecmp(cmd, "whois") == 0 && args) { if (strchr(args, ' ')) irc_send_raw("WHOIS %s", args); else irc_send_raw("WHOIS %s", args); } else if (strcasecmp(cmd, "wii") == 0 && args) { irc_send_raw("WHOIS %s %s", args, args); } else if (strcasecmp(cmd, "names") == 0) { const char *chan = args ? args : current_channel(); if (chan[0]) irc_send_raw("NAMES %s", chan); } else if (strcasecmp(cmd, "topic") == 0) { if (args && strchr(args, ' ')) { /* /topic #channel new topic */ char *chan = args; char *text = strchr(args, ' '); *text++ = '\0'; irc_send_raw("TOPIC %s :%s", chan, text); } else { /* /topic or /topic #channel — query topic */ const char *chan = args ? args : current_channel(); if (chan[0]) irc_send_raw("TOPIC %s", chan); } } else if (strcasecmp(cmd, "quit") == 0) { irc_send_raw("QUIT :%s", args ? args : "See you later"); close(sock_fd); exit(0); } else if (strcasecmp(cmd, "raw") == 0 && args) { irc_send_raw("%s", args); } else { wprintf(current_level, "Unknown command: /%s\n", cmd); } } else { const char *chan = current_channel(); /* If on status/msg window with a query target, send there */ if (chan[0] == '\0' && query_target[0]) { char pfx[IRC_MAX]; snprintf(pfx, sizeof(pfx), "PRIVMSG %s :", query_target); irc_send_converted(pfx, (unsigned char *)line, len); wprintf(WL_MSG, "-> %s: %s\n", query_target, line); return; } if (chan[0] == '\0') { wprintf(current_level, "* Not in a channel on this window.\n"); return; } char pfx[IRC_MAX]; snprintf(pfx, sizeof(pfx), "PRIVMSG %s :", chan); irc_send_converted(pfx, (unsigned char *)line, len); wprintf(current_level, "<%s> %s\n", nick, line); } } /* Count display columns for a UTF-8 byte string (skips ANSI escapes) */ static size_t display_cols(const char *buf, size_t bytes) { size_t cols = 0; size_t i = 0; while (i < bytes) { unsigned char c = (unsigned char)buf[i]; /* Skip ANSI escape sequences */ if (c == 0x1B && i + 1 < bytes && buf[i+1] == '[') { i += 2; while (i < bytes && !((unsigned char)buf[i] >= 0x40 && (unsigned char)buf[i] <= 0x7E)) i++; if (i < bytes) i++; /* skip final byte */ continue; } if (c < 0x80) { i++; cols++; } else if ((c & 0xE0) == 0xC0) { /* 2-byte: U+0080..U+07FF — all single width */ i += 2; cols++; } else if ((c & 0xF0) == 0xE0) { /* 3-byte: U+0800..U+FFFF — CJK ranges are double width */ unsigned int cp = ((c & 0x0F) << 12); if (i + 1 < bytes) cp |= ((unsigned char)buf[i+1] & 0x3F) << 6; if (i + 2 < bytes) cp |= ((unsigned char)buf[i+2] & 0x3F); i += 3; /* Wide: CJK Unified, Katakana, Hiragana, Hangul, fullwidth, etc. */ if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0xA4CF && cp != 0x303F) || (cp >= 0xAC00 && cp <= 0xD7A3) || (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE6F) || (cp >= 0xFF01 && cp <= 0xFF60) || (cp >= 0xFFE0 && cp <= 0xFFE6)) cols += 2; else cols++; } else if ((c & 0xF8) == 0xF0) { /* 4-byte: U+10000..U+10FFFF — most are double width (emoji etc) */ i += 4; cols += 2; } else { i++; cols++; } } return cols; } /* Return number of bytes in the UTF-8 char ending before pos */ static size_t utf8_back(const char *buf, size_t pos) { size_t n = 1; while (n < pos && n < 4 && ((unsigned char)buf[pos - n] & 0xC0) == 0x80) n++; return n; } static void redraw_input(const char *input_line, size_t input_len, size_t input_pos) { int avail = term_cols - 2; /* columns available after "> " */ size_t total_cols = display_cols(input_line, input_len); size_t cpos_cols = display_cols(input_line, input_pos); printf("\033[%d;1H\033[K\033[32m>\033[0m ", term_rows); if ((int)total_cols <= avail) { /* Fits entirely */ fwrite(input_line, 1, input_len, stdout); printf("\033[%d;%dH", term_rows, (int)(cpos_cols + 3)); } else { /* Scroll horizontally: show a window around cursor */ size_t win_start_bytes = 0; size_t win_start_cols = 0; /* If cursor is past the visible area, shift window */ if ((int)cpos_cols >= avail) { /* Move window so cursor is near the right edge */ size_t target_cols = cpos_cols - (size_t)(avail - 1); size_t i = 0, cols = 0; while (i < input_len && cols < target_cols) { unsigned char c = (unsigned char)input_line[i]; if (c < 0x80) i++; else if ((c & 0xE0) == 0xC0) i += 2; else if ((c & 0xF0) == 0xE0) i += 3; else if ((c & 0xF8) == 0xF0) i += 4; else i++; cols++; } win_start_bytes = i; win_start_cols = cols; } /* Write characters that fit in avail columns */ size_t i = win_start_bytes; int cols_written = 0; while (i < input_len && cols_written < avail) { unsigned char c = (unsigned char)input_line[i]; size_t clen = 1; if (c >= 0x80) { if ((c & 0xE0) == 0xC0) clen = 2; else if ((c & 0xF0) == 0xE0) clen = 3; else if ((c & 0xF8) == 0xF0) clen = 4; } fwrite(input_line + i, 1, clen, stdout); i += clen; cols_written++; } int cursor_col = (int)(cpos_cols - win_start_cols + 3); printf("\033[%d;%dH", term_rows, cursor_col); } fflush(stdout); } static void cleanup(void) { printf("\033[1;%dr", term_rows); /* reset scroll region */ printf("\033[%d;1H\033[K", term_rows - 1); printf("\033[%d;1H\033[K\n", term_rows); tcsetattr(STDIN_FILENO, TCSANOW, &orig_term); if (logfp) fclose(logfp); } static void usage(const char *prog) { fprintf(stderr, "Usage: %s [port]\n", prog); exit(1); } int main(int argc, char *argv[]) { const char *host, *port = "6667"; if (argc < 3) usage(argv[0]); snprintf(nick, sizeof(nick), "%s", argv[1]); host = argv[2]; if (argc >= 4) port = argv[3]; memset(win_chans, 0, sizeof(win_chans)); ai_config_load(); logfp = fopen("irc.log", "a"); if (logfp) fprintf(logfp, "--- Session started ---\n"); /* Set terminal to raw mode */ struct termios raw_term; tcgetattr(STDIN_FILENO, &orig_term); raw_term = orig_term; raw_term.c_lflag &= ~(ICANON | ECHO); raw_term.c_cc[VMIN] = 1; raw_term.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &raw_term); atexit(cleanup); signal(SIGINT, sigint_handler); signal(SIGWINCH, sigwinch_handler); /* Set up scrolling region (leave last 2 rows for status + input) */ get_term_size(); printf("\033[2J\033[H"); /* clear screen */ printf("\033[1;%dr", term_rows - 2); printf("\033[1;1H"); /* cursor to top of scroll region */ draw_statusbar(); wprintf(WL_STATUS, "Connecting to %s:%s as %s...\n", host, port, nick); sock_fd = irc_connect(host, port); wprintf(WL_STATUS, "Connected.\n"); irc_send_raw("NICK %s", nick); const char *user = getenv("USER"); if (!user) user = nick; const char *realname = nick; struct passwd *pw = getpwuid(getuid()); if (pw && pw->pw_gecos && pw->pw_gecos[0]) { /* GECOS may have commas; use only first field */ static char gecos[128]; snprintf(gecos, sizeof(gecos), "%s", pw->pw_gecos); char *comma = strchr(gecos, ','); if (comma) *comma = '\0'; realname = gecos; } irc_send_raw("USER %s 0 * :%s", user, realname); draw_statusbar(); fd_set fds; char input_line[BUF_SIZE]; size_t input_pos = 0; /* cursor position */ size_t input_len = 0; /* total length */ char yank_buf[BUF_SIZE] = ""; size_t yank_len = 0; int esc_pending = 0; int esc_bracket = 0; /* got ESC [ */ /* Command history */ #define HIST_SIZE 50 char history[HIST_SIZE][BUF_SIZE]; int hist_count = 0; int hist_pos = 0; /* current browse position */ int tab_idx = -1; /* current tab completion index */ size_t tab_prefix_len = 0; /* length of prefix being completed */ size_t tab_start = 0; /* byte position where completion word starts */ size_t tab_end = 0; /* byte position where last completion ends */ for (;;) { FD_ZERO(&fds); FD_SET(sock_fd, &fds); FD_SET(STDIN_FILENO, &fds); int maxfd = sock_fd > STDIN_FILENO ? sock_fd : STDIN_FILENO; /* Add translation pipes to select */ for (int ti = 0; ti < translate_count; ti++) { FD_SET(translate_pending[ti].fd, &fds); if (translate_pending[ti].fd > maxfd) maxfd = translate_pending[ti].fd; } struct timeval tv; tv.tv_sec = 0; tv.tv_usec = 500000; int ret = select(maxfd + 1, &fds, NULL, NULL, &tv); if (ret < 0) { if (errno == EINTR) continue; die("select"); } /* Redraw status bar every minute for clock update */ { static int last_min = -1; time_t now = time(NULL); int cur_min = localtime(&now)->tm_min; if (cur_min != last_min) { last_min = cur_min; draw_statusbar(); redraw_input(input_line, input_len, input_pos); } } /* Check translation results */ for (int ti = 0; ti < translate_count; ) { if (FD_ISSET(translate_pending[ti].fd, &fds)) { char tbuf[1024]; int n = read(translate_pending[ti].fd, tbuf, sizeof(tbuf) - 1); close(translate_pending[ti].fd); waitpid(translate_pending[ti].pid, NULL, 0); if (n > 0) { tbuf[n] = '\0'; /* Strip trailing whitespace */ while (n > 0 && (tbuf[n-1] == '\n' || tbuf[n-1] == '\r' || tbuf[n-1] == ' ')) tbuf[--n] = '\0'; if (n == 0) goto skip_translate; /* Parse "english|||swedish" format */ char *sep = strstr(tbuf, "|||"); char *english = tbuf; char *skip_part = NULL; if (sep) { *sep = '\0'; skip_part = sep + 3; /* Strip leading space */ while (*skip_part == ' ') skip_part++; } /* Compare skip_part to original — if similar, suppress */ int suppress = 0; if (skip_part && translate_pending[ti].original[0]) { /* Simple word overlap: count matching words */ char orig_lower[512], skip_lower[512]; snprintf(orig_lower, sizeof(orig_lower), "%s", translate_pending[ti].original); snprintf(skip_lower, sizeof(skip_lower), "%s", skip_part); for (char *p = orig_lower; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; for (char *p = skip_lower; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; /* Count words in original that appear in skip translation */ int total = 0, matches = 0; char *tok = strtok(orig_lower, " ,.!?:;"); while (tok) { if (strlen(tok) > 2) { total++; if (strstr(skip_lower, tok)) matches++; } tok = strtok(NULL, " ,.!?:;"); } if (total > 0 && matches * 100 / total >= 50) suppress = 1; } /* Also check old SKIP response */ if (strcasecmp(english, "SKIP") == 0 || strncasecmp(english, "SKIP", 4) == 0 || strncasecmp(english, "I cannot", 8) == 0 || strncasecmp(english, "I can't", 7) == 0) suppress = 1; /* Suppress if English translation matches original */ if (!suppress && translate_pending[ti].original[0]) { char orig_l[512], eng_l[1024]; snprintf(orig_l, sizeof(orig_l), "%s", translate_pending[ti].original); snprintf(eng_l, sizeof(eng_l), "%s", english); for (char *p = orig_l; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; for (char *p = eng_l; *p; p++) if (*p >= 'A' && *p <= 'Z') *p += 32; int total = 0, matches = 0; char orig_copy[512]; snprintf(orig_copy, sizeof(orig_copy), "%s", orig_l); char *tok = strtok(orig_copy, " ,.!?:;"); while (tok) { if (strlen(tok) > 2) { total++; if (strstr(eng_l, tok)) matches++; } tok = strtok(NULL, " ,.!?:;"); } if (total > 0 && matches * 100 / total >= 50) suppress = 1; } if (!suppress && english[0]) { /* Strip trailing whitespace from english */ size_t elen = strlen(english); while (elen > 0 && (english[elen-1] == ' ' || english[elen-1] == '\n')) english[--elen] = '\0'; wprintf(translate_pending[ti].level, " \033[3m%s\033[0m\n", english); if (translate_pending[ti].target[0]) { char pfx[IRC_MAX]; snprintf(pfx, sizeof(pfx), "PRIVMSG %s :", translate_pending[ti].target); irc_send_converted(pfx, (unsigned char *)english, strlen(english)); wprintf(translate_pending[ti].level, "<%s> %s\n", nick, english); } } } skip_translate: translate_count--; if (ti < translate_count) translate_pending[ti] = translate_pending[translate_count]; } else { ti++; } } if (got_sigint) { got_sigint = 0; printf("\033[%d;1H\033[KWanna quit? [y/N] ", term_rows); fflush(stdout); char qbuf[16]; size_t qpos = 0; int quit = 0; for (;;) { unsigned char qch; ssize_t r = read(STDIN_FILENO, &qch, 1); if (r <= 0) break; if (qch == '\r' || qch == '\n') { quit = (qpos > 0 && (qbuf[0] == 'y' || qbuf[0] == 'Y')); break; } else if ((qch == 127 || qch == 0x08) && qpos > 0) { qpos--; printf("\b \b"); fflush(stdout); } else if (qch >= 32 && qpos < sizeof(qbuf) - 1) { qbuf[qpos++] = qch; ssize_t w = write(STDOUT_FILENO, &qch, 1); (void)w; } } if (quit) { irc_send_raw("QUIT :Leaving"); break; } redraw_input(input_line, input_len, input_pos); continue; } if (got_sigwinch) { got_sigwinch = 0; get_term_size(); printf("\033[1;%dr", term_rows - 2); redraw_window(); redraw_input(input_line, input_len, input_pos); continue; } if (FD_ISSET(sock_fd, &fds)) { ssize_t n = read(sock_fd, recv_buf + recv_len, sizeof(recv_buf) - recv_len - 1); if (n <= 0) { printf("* Disconnected from server.\n"); break; } recv_len += (size_t)n; recv_buf[recv_len] = '\0'; process_recv(); } if (FD_ISSET(STDIN_FILENO, &fds)) { unsigned char ch; ssize_t n = read(STDIN_FILENO, &ch, 1); if (n <= 0) { irc_send_raw("QUIT :EOF"); break; } if (esc_pending) { esc_pending = 0; if (ch == '[') { esc_bracket = 1; continue; } if (ch >= '1' && ch <= '9') { int lvl = ch - '1'; if (lvl < WL_MAX) { current_level = lvl; win_activity[lvl] = 0; scroll_offset = 0; redraw_window(); redraw_input(input_line, input_len, input_pos); } continue; } } if (esc_bracket) { esc_bracket = 0; if (ch == 'A') { /* Arrow up: previous history */ if (hist_pos > 0) { hist_pos--; input_len = strlen(history[hist_pos]); memcpy(input_line, history[hist_pos], input_len); input_pos = input_len; redraw_input(input_line, input_len, input_pos); } } else if (ch == 'B') { /* Arrow down: next history */ if (hist_pos < hist_count - 1) { hist_pos++; input_len = strlen(history[hist_pos]); memcpy(input_line, history[hist_pos], input_len); input_pos = input_len; redraw_input(input_line, input_len, input_pos); } else { hist_pos = hist_count; input_len = 0; input_pos = 0; redraw_input(input_line, input_len, input_pos); } } else if (ch == 'C') { /* Arrow right */ if (input_pos < input_len) { unsigned char c = input_line[input_pos]; size_t clen = 1; if ((c & 0xE0) == 0xC0) clen = 2; else if ((c & 0xF0) == 0xE0) clen = 3; else if ((c & 0xF8) == 0xF0) clen = 4; input_pos += clen; if (input_pos > input_len) input_pos = input_len; redraw_input(input_line, input_len, input_pos); } } else if (ch == 'D') { /* Arrow left */ if (input_pos > 0) { input_pos -= utf8_back(input_line, input_pos); redraw_input(input_line, input_len, input_pos); } } continue; } /* Reset tab completion on any non-tab key */ if (ch != 0x09) tab_idx = -1; if (ch == 0x1B) { esc_pending = 1; } else if (ch == '\r' || ch == '\n') { printf("\033[%d;1H\033[K", term_rows); input_line[input_len] = '\0'; if (input_len > 0) { /* Save to history */ if (hist_count < HIST_SIZE) { memcpy(history[hist_count], input_line, input_len + 1); hist_count++; } else { memmove(history[0], history[1], (HIST_SIZE-1) * BUF_SIZE); memcpy(history[HIST_SIZE-1], input_line, input_len + 1); } hist_pos = hist_count; handle_input(input_line); } input_pos = 0; input_len = 0; printf("\033[%d;1H\033[32m>\033[0m ", term_rows); fflush(stdout); } else if (ch == 0x01) { /* Ctrl-A: beginning of line */ input_pos = 0; redraw_input(input_line, input_len, input_pos); } else if (ch == 0x05) { /* Ctrl-E: end of line */ input_pos = input_len; redraw_input(input_line, input_len, input_pos); } else if (ch == 0x15) { /* Ctrl-U: kill to beginning */ if (input_pos > 0) { yank_len = input_pos; memcpy(yank_buf, input_line, yank_len); memmove(input_line, input_line + input_pos, input_len - input_pos); input_len -= input_pos; input_pos = 0; redraw_input(input_line, input_len, input_pos); } } else if (ch == 0x19) { /* Ctrl-Y: yank (paste) */ if (yank_len > 0 && input_len + yank_len < sizeof(input_line) - 1) { memmove(input_line + input_pos + yank_len, input_line + input_pos, input_len - input_pos); memcpy(input_line + input_pos, yank_buf, yank_len); input_len += yank_len; input_pos += yank_len; redraw_input(input_line, input_len, input_pos); } } else if (ch == 0x0C) { /* Ctrl-L: redraw screen */ get_term_size(); printf("\033[2J\033[H"); printf("\033[1;%dr", term_rows - 2); redraw_window(); redraw_input(input_line, input_len, input_pos); } else if (ch == 0x0B) { /* Ctrl-K: kill to end */ yank_len = input_len - input_pos; memcpy(yank_buf, input_line + input_pos, yank_len); input_len = input_pos; redraw_input(input_line, input_len, input_pos); } else if (ch == 0x09) { /* Tab: nick completion */ char (*nlist)[NICK_LEN] = NULL; int ncount = 0; /* Check if completing after /msg or /w */ int after_msg = 0; input_line[input_len] = '\0'; if (strncasecmp(input_line, "/msg ", 5) == 0 || strncasecmp(input_line, "/w ", 3) == 0) { after_msg = 1; } /* On window 1 with empty input, auto-insert /msg */ if (current_level == WL_STATUS && input_len == 0 && pm_nick_count > 0) { after_msg = 1; memcpy(input_line, "/msg ", 5); input_len = 5; input_pos = 5; tab_start = 5; tab_prefix_len = 0; tab_end = 5; tab_idx = 0; } if (after_msg) { nlist = pm_nicks; ncount = pm_nick_count; } else if (current_level >= WL_CHAN) { int cidx = current_level - WL_CHAN; nlist = win_chans[cidx].nicks; ncount = win_chans[cidx].nick_count; } else { nlist = pm_nicks; ncount = pm_nick_count; } if (ncount > 0) { if (tab_idx < 0) { tab_start = input_pos; while (tab_start > 0 && input_line[tab_start-1] != ' ') tab_start--; tab_prefix_len = input_pos - tab_start; tab_end = input_pos; tab_idx = 0; } else { input_pos = tab_end; tab_idx++; } int found = 0; for (int tries = 0; tries < ncount; tries++) { int ni = (tab_idx + tries) % ncount; if (tab_prefix_len == 0 || strncasecmp(nlist[ni], input_line + tab_start, tab_prefix_len) == 0) { tab_idx = ni; const char *compl = nlist[ni]; size_t clen = strlen(compl); char suffix[4] = ""; if (tab_start == 0 && current_level >= WL_CHAN) strcpy(suffix, ": "); else strcpy(suffix, " "); size_t slen = strlen(suffix); size_t tail = input_len - tab_end; input_len = tab_start + clen + slen + tail; if (input_len >= sizeof(input_line) - 1) input_len = sizeof(input_line) - 1; memmove(input_line + tab_start + clen + slen, input_line + tab_end, tail); memcpy(input_line + tab_start, compl, clen); memcpy(input_line + tab_start + clen, suffix, slen); tab_end = tab_start + clen + slen; input_pos = tab_end; redraw_input(input_line, input_len, input_pos); tab_idx++; found = 1; break; } } if (!found) tab_idx = -1; } continue; /* don't reset tab state */ } else if (ch == 0x10) { /* Ctrl-P: page up in scrollback */ int page = term_rows - 3; if (page < 1) page = 1; scroll_offset += page; int max_scroll = win_buf[current_level].count - (term_rows - 2); if (max_scroll < 0) max_scroll = 0; if (scroll_offset > max_scroll) scroll_offset = max_scroll; redraw_window(); redraw_input(input_line, input_len, input_pos); } else if (ch == 0x0E) { /* Ctrl-N: page down in scrollback */ int page = term_rows - 3; if (page < 1) page = 1; scroll_offset -= page; if (scroll_offset < 0) scroll_offset = 0; redraw_window(); redraw_input(input_line, input_len, input_pos); } else if (ch == 127 || ch == 0x08) { if (input_pos > 0) { size_t clen = utf8_back(input_line, input_pos); memmove(input_line + input_pos - clen, input_line + input_pos, input_len - input_pos); input_pos -= clen; input_len -= clen; redraw_input(input_line, input_len, input_pos); } } else if (ch == 0x04) { irc_send_raw("QUIT :EOF"); break; } else if (ch >= 32 && input_len < sizeof(input_line) - 1) { /* For UTF-8 lead bytes, figure out how many * bytes to expect and read them all */ size_t seq_len = 1; if ((ch & 0xE0) == 0xC0) seq_len = 2; else if ((ch & 0xF0) == 0xE0) seq_len = 3; else if ((ch & 0xF8) == 0xF0) seq_len = 4; unsigned char seq[4]; seq[0] = ch; for (size_t si = 1; si < seq_len; si++) { unsigned char cb; ssize_t r = read(STDIN_FILENO, &cb, 1); if (r <= 0) break; seq[si] = cb; } if (input_len + seq_len < sizeof(input_line) - 1) { memmove(input_line + input_pos + seq_len, input_line + input_pos, input_len - input_pos); memcpy(input_line + input_pos, seq, seq_len); input_pos += seq_len; input_len += seq_len; redraw_input(input_line, input_len, input_pos); } } } } close(sock_fd); return 0; }