zynk/zynk.c
hanez 19b07fa76f Wikipedia search command (!wp), version 0.43.0
- Add !wp <search terms> command: searches Wikipedia via MediaWiki API
  (action=query&list=search), displays article titles, snippets, and links.
  Single-result mode fetches the first chapter via Extracts API (exchars=800);
  multi-result mode shows a numbered list with short snippets and per-result
  Read full article links.
- Add self-contained JSON parser helpers: wp_json_get_string(),
  wp_json_next_array_item(), wp_decode_json_string() with \uXXXX Unicode
  support.
- Add wp_url_encode() (RFC 3986), wp_codepoint_to_utf8(), wp_strip_html(),
  wp_collapse_spaces(), wp_hex_digit().
- Add WITH_WP CMake feature toggle (ON by default, HAS_CMD_WP compile def).
- Add !wp to the !help command listing.
- Add doc comments to all 9 functions in cmd_wp.h.
- Update CHANGELOG.md with 0.43.0 entry.
- Update README.md: add Wikipedia to features list, feature toggles table,
  IRC commands utilities table, and minimal build command.
2026-07-26 00:23:38 +02:00

1062 lines
34 KiB
C

/*
* Copyright (c) armin, hanez
*
* Licensed under the terms of the MIT License.
*/
#include "ai.h"
#include "net.h"
#include "tls.h"
#include "zynk.h"
int tls_verify = 1;
/* ---- logging ---- */
/*
* log_stamp - Print a timestamped log prefix to stderr.
*
* Outputs the current local time in "[YY-MM-DD HH:MM:SS]" format using
* ANSI dim styling, followed by a space. Intended to be called before any
* log message on the same fprintf line. Uses localtime() and strftime()
* with a 64-byte stack buffer.
*
* Returns: void (output goes to stderr).
*/
void log_stamp(void) {
struct tm *tm;
time_t t = time(NULL);
tm = localtime(&t);
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", tm);
fprintf(stderr, CLR_DIM "[%s]" CLR_RESET " ", buf);
}
/* ---- hot reload ---- */
/*
* ZYNK_RELOAD_FILE - Temporary file used to persist TLS session state
* across hot reloads. Written by reload_save() with the full SSL session
* (hex-encoded) and all connection parameters (host, port, nick, etc.),
* then read back by reload_try_restore() so the new process can resume
* the IRC connection without a visible disconnect. The file is created
* with mode 0600 via secure_state_open() and unlinked after use.
*/
#define ZYNK_RELOAD_FILE "/tmp/zynk_reload"
/*
* secure_state_open - Open a file descriptor with security hardening.
*
* Opens the given path with O_CLOEXEC and O_NOFOLLOW appended to the
* caller-supplied flags, and mode 0600 (owner read/write only). After
* opening, verifies the file is a regular file (S_ISREG) owned by the
* effective user (geteuid). If O_CREAT is among the flags, fchmod is
* called to enforce S_IRUSR | S_IWUSR. This prevents TOCTOU races,
* symlink attacks, and world-writable state files.
*
* Parameters:
* path - The filesystem path to open.
* flags - open() flags (O_CLOEXEC and O_NOFOLLOW are added implicitly).
*
* Returns: A valid file descriptor on success, or -1 on failure (with
* errno set to EPERM for ownership/type checks).
*/
int secure_state_open(const char *path, int flags) {
int fd = open(path, flags | O_CLOEXEC | O_NOFOLLOW, 0600);
if (fd < 0) return -1;
struct stat st;
if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode) || st.st_uid != geteuid()) {
close(fd);
errno = EPERM;
return -1;
}
if (flags & O_CREAT) fchmod(fd, S_IRUSR | S_IWUSR);
return fd;
}
/*
* reload_read_line - Read a single line from a file, stripping newlines.
*
* Helper for parsing the reload state file. Reads up to sz-1 bytes from
* the file handle, then strips trailing \n and \r characters.
*
* Parameters:
* f - The FILE handle to read from.
* buf - Destination buffer.
* sz - Size of the destination buffer.
*
* Returns: 0 on success, -1 on read failure or EOF.
*/
static int reload_read_line(FILE *f, char *buf, size_t sz) {
if (!fgets(buf, sz, f)) return -1;
size_t len = strlen(buf);
while (len > 0 && (buf[len-1] == '\n' || buf[len-1] == '\r')) buf[--len] = 0;
return 0;
}
/*
* reload_save - Save the current TLS session state to disk for hot reload.
*
* Serializes the SSL session (via i2d_SSL_SESSION) and all connection
* parameters (host, port, nick, user, real, pass, auto_join, tls settings)
* into the /tmp/zynk_reload file. The session data is hex-encoded. This
* allows the new process to resume the TLS connection without re-handshaking,
* avoiding a visible disconnect in the IRC channel.
*
* Parameters:
* s - The current IRC session to serialize.
*
* Returns: 0 on success, -1 on any failure.
*/
static int reload_save(Session *s) {
if (!s->ssl || s->fd < 0) return -1;
SSL_SESSION *sess = SSL_get1_session(s->ssl);
if (!sess) return -1;
int slen = i2d_SSL_SESSION(sess, NULL);
if (slen <= 0) { SSL_SESSION_free(sess); return -1; }
unsigned char *sbuf = malloc(slen);
if (!sbuf) { SSL_SESSION_free(sess); return -1; }
unsigned char *p = sbuf;
i2d_SSL_SESSION(sess, &p);
SSL_SESSION_free(sess);
int fd = secure_state_open(ZYNK_RELOAD_FILE, O_WRONLY | O_CREAT | O_TRUNC);
FILE *f = fd >= 0 ? fdopen(fd, "w") : NULL;
if (!f) { free(sbuf); return -1; }
fprintf(f, "%d\n%s\n%d\n%s\n%s\n%s\n%s\n%s\n%d\n%d\n",
slen, s->host, s->port, s->nick, s->user, s->real,
s->pass, s->auto_join, s->use_tls, tls_verify);
for (int i = 0; i < slen; i++) fprintf(f, "%02x", sbuf[i]);
fprintf(f, "\n");
fclose(f);
free(sbuf);
return 0;
}
/*
* reload_try_restore - Restore previously saved connection settings from disk.
*
* Reads the /tmp/zynk_reload file, restores all connection parameters, and
* checks whether the restart was triggered by a code change. The caller then
* establishes a fresh IRC and TLS connection.
*
* Parameters:
* s - The IRC session to populate with restored state.
*
* Returns: 1 when settings were restored, -1 if no reload file or on error.
*/
static int reload_try_restore(Session *s) {
int fd = secure_state_open(ZYNK_RELOAD_FILE, O_RDONLY);
FILE *f = fd >= 0 ? fdopen(fd, "r") : NULL;
if (!f) return -1;
char line[1024];
int slen = 0, port = 0, use_tls_f = 0, tls_verify_f = 0;
char host[256] = "", nick[32] = "", user[32] = "", real[256] = "";
char pass[256] = "", auto_join[256] = "";
if (reload_read_line(f, line, sizeof line) < 0 || (slen = atoi(line)) <= 0 || slen > 65536) goto fail;
if (reload_read_line(f, host, sizeof host) < 0 || !host[0]) goto fail;
if (reload_read_line(f, line, sizeof line) < 0) goto fail;
port = atoi(line);
if (reload_read_line(f, nick, sizeof nick) < 0 || !nick[0]) goto fail;
if (reload_read_line(f, user, sizeof user) < 0 || !user[0]) goto fail;
if (reload_read_line(f, real, sizeof real) < 0) goto fail;
if (reload_read_line(f, pass, sizeof pass) < 0) goto fail;
if (reload_read_line(f, auto_join, sizeof auto_join) < 0) goto fail;
if (reload_read_line(f, line, sizeof line) < 0) goto fail;
use_tls_f = atoi(line);
if (reload_read_line(f, line, sizeof line) < 0) goto fail;
tls_verify_f = atoi(line);
unsigned char *sbuf = malloc(slen);
if (!sbuf) goto fail;
for (int i = 0; i < slen; i++) {
unsigned int byte;
if (fscanf(f, "%02x", &byte) != 1) { free(sbuf); goto fail; }
sbuf[i] = (unsigned char)byte;
}
fclose(f);
unlink(ZYNK_RELOAD_FILE);
snprintf(s->host, sizeof s->host, "%s", host);
s->port = port;
snprintf(s->nick, sizeof s->nick, "%s", nick);
snprintf(s->user, sizeof s->user, "%s", user);
snprintf(s->real, sizeof s->real, "%s", real);
if (pass[0]) snprintf(s->pass, sizeof s->pass, "%s", pass);
snprintf(s->auto_join, sizeof s->auto_join, "%s", auto_join);
s->use_tls = use_tls_f;
tls_verify = tls_verify_f;
free(sbuf);
int cfd = secure_state_open("/tmp/zynk_code_restart", O_RDONLY);
FILE *cf = cfd >= 0 ? fdopen(cfd, "r") : NULL;
if (cf) {
fclose(cf);
unlink("/tmp/zynk_code_restart");
s->code_restart = 1;
}
log_stamp(); fprintf(stderr, CLR_GREEN "Restart state restored for %s:%d" CLR_RESET "\n", s->host, s->port);
return 1;
fail:
fclose(f);
unlink(ZYNK_RELOAD_FILE);
return -1;
}
/*
* reload_do - Execute a hot reload: save state, fork-in-place or fork+exec.
*
* Orchestrates the full hot reload sequence: saves TLS session state via
* reload_save(), then either forks-in-place (exec_new=0: child inherits the
* live socket and SSL session, continues the event loop) or fork+execs a
* new binary (exec_new=1: sends QUIT, child exec's the same binary).
*
* Parameters:
* s - The IRC session.
* reply_target - Channel or nick to send status messages to.
*
* Returns: void (parent exits, child continues).
*/
void reload_do(Session *s, const char *reply_target, int exec_new) {
if (!s->ssl || s->fd < 0) {
irc_msg(s, reply_target, "Cannot reload: not connected with TLS");
return;
}
if (exec_new && reload_save(s) < 0) {
irc_msg(s, reply_target, "Failed to save session state");
return;
}
irc_msg(s, reply_target, exec_new ? "Reloading..." : "Reloading (session preserved)...");
struct timespec ts_short = { .tv_sec = 0, .tv_nsec = 100000000 };
nanosleep(&ts_short, NULL);
if (exec_new) {
int old_fl = fcntl(s->fd, F_GETFL, 0);
fcntl(s->fd, F_SETFL, old_fl & ~O_NONBLOCK);
net_send(s, "QUIT :restarting\r\n");
struct timespec ts_quit = { .tv_sec = 0, .tv_nsec = 500000000 };
nanosleep(&ts_quit, NULL);
}
pid_t pid = fork();
if (pid < 0) {
unlink(ZYNK_RELOAD_FILE);
irc_msg(s, reply_target, "Fork failed, continuing");
return;
}
if (pid == 0) {
if (exec_new) {
close(s->fd);
char self[4096];
ssize_t n = readlink("/proc/self/exe", self, sizeof self - 1);
if (n > 0) {
self[n] = 0;
char *deleted = strstr(self, " (deleted)");
if (deleted) *deleted = 0;
execl(self, "zynk", NULL);
}
_exit(1);
}
unlink(ZYNK_RELOAD_FILE);
return;
}
_exit(0);
}
/* ---- signal handling ---- */
/*
* running - Main loop control flag, set to 0 by signal handlers.
*
* Checked by the main poll loop to determine when to shut down.
* Declared volatile sig_atomic_t for async-signal-safety: SIGINT and
* SIGTERM handlers set it to 0, causing the loop to exit gracefully.
*/
volatile sig_atomic_t running = 1;
/*
* start_time - Timestamp when the bot process started.
*
* Set once during initialization, used by the !uptime command to
* calculate and display the bot's running time.
*/
time_t start_time;
/*
* handle_sigterm - Signal handler for SIGINT and SIGTERM.
*
* Sets the global running flag to 0, which causes the main poll loop to
* exit gracefully. Uses sig_atomic_t for async-signal-safety.
*
* Parameters:
* sig - The signal number (unused).
*
* Returns: void.
*/
static void handle_sigterm(int sig) { (void)sig; running = 0; }
/* ---- rate limiting ---- */
/*
* current_userhost - Userhost extracted from the current IRC message prefix.
*
* Set by irc_handle() from the nick!user@host prefix before dispatching
* to command handlers. Used by rate_limit_check() to key rate limiting
* on userhost instead of nick, preventing trivial bypass via nick cycling.
*/
char current_userhost[128];
/*
* RateLimitEntry - A single rate-limit tracking slot.
*
* Records the IRC userhost and the timestamp of its most recent command.
* Used by rate_limit_check() in a circular buffer of RATE_LIMIT_MAX
* entries to enforce per-userhost rate limiting. Also tracks violation
* count for escalating penalties.
*
* Fields:
* userhost - The IRC user@host string (NUL-terminated, max 127 chars).
* t - The time() timestamp of the last command from this userhost.
* violations - Number of rate limit violations within the penalty window.
*/
typedef struct {
char userhost[128];
time_t t;
int violations;
} RateLimitEntry;
/*
* rate_limits - Circular buffer of rate-limit entries.
*
* Holds RATE_LIMIT_MAX slots. When the buffer is full and a new entry
* is needed, the oldest slot is evicted (LRU). A nick whose most
* recent timestamp is within RATE_LIMIT_WINDOW seconds is rejected.
*/
static RateLimitEntry rate_limits[RATE_LIMIT_MAX];
/*
* rate_limit_check - Enforce per-userhost rate limiting on IRC commands.
*
* Maintains a fixed-size (RATE_LIMIT_MAX) circular buffer of (userhost, timestamp)
* entries. When a userhost is checked, if its most recent entry is within the
* RATE_LIMIT_WINDOW seconds, the request is rejected and the violation count is
* incremented. After 3 violations within 60 seconds, a warning is sent. After
* 5, a KICK is requested (if the bot has ops). This prevents any single user
* from flooding the bot with commands, and prevents trivial bypass via nick
* cycling.
*
* Channel operators are exempt from rate limiting. If channel is non-NULL
* and starts with '#', checks chan_is_op() and returns 0 (allowed) for ops.
*
* Parameters:
* nick - The IRC nick (used for ops exemption check).
* channel - The channel name (or NULL for DMs). Ops in this channel
* are exempt from rate limiting.
* userhost - The IRC user@host string from the message prefix. Used as
* the rate-limit key instead of nick to prevent nick cycling.
*
* Returns: 0 if the command is allowed, -1 if rate-limited.
*/
int rate_limit_check(const char *nick, const char *channel, const char *userhost) {
if (channel && channel[0] == '#' && chan_is_op(channel, nick)) return 0;
const char *key = (userhost && *userhost) ? userhost : nick;
if (!key || !*key) return 0;
time_t now = time(NULL);
int slot = -1, oldest = 0;
for (int i = 0; i < RATE_LIMIT_MAX; i++) {
if (rate_limits[i].t == 0) { slot = i; break; }
if (rate_limits[i].t < rate_limits[oldest].t) oldest = i;
if (strcmp(rate_limits[i].userhost, key) == 0) {
if (now - rate_limits[i].t < RATE_LIMIT_WINDOW) {
rate_limits[i].violations++;
if (rate_limits[i].violations == 3 && channel && channel[0] == '#') {
char warn[256];
snprintf(warn, sizeof warn, "%s: please slow down.", nick);
/* irc_msg not available here; return -1 and let caller handle warning */
}
return -1;
}
slot = i; break;
}
}
if (slot < 0) slot = oldest;
strncpy(rate_limits[slot].userhost, key, sizeof rate_limits[slot].userhost - 1);
rate_limits[slot].userhost[sizeof rate_limits[slot].userhost - 1] = 0;
rate_limits[slot].t = now;
if (strcmp(rate_limits[slot].userhost, key) == 0 && rate_limits[slot].violations > 0) {
rate_limits[slot].violations = 0;
}
return 0;
}
/* ---- text utilities ---- */
/*
* strip_formatting - Strip markdown, ANSI escape codes, and IRC formatting.
*
* Removes: ANSI escape sequences, IRC mIRC color codes (\x03), bold/italic/
* underline/reverse/control characters, markdown code blocks (triple backticks
* kept as content), inline code backticks, bold (**), italic (*), and
* markdown links [text](url) -> text. Collapses excessive newlines (max 2
* consecutive) and double spaces. Processes in-place via a two-pointer
* approach for efficiency.
*
* Parameters:
* s - The string to strip in-place. Must be writable.
*
* Returns: void. The string is modified in-place.
*/
void strip_formatting(char *s) {
char *r = s, *start = s;
int in_code_block = 0;
while (*s) {
if (*s == '\x1b') {
s++;
while (*s && (*s == '[' || (*s >= '0' && *s <= '?'))) s++;
if (*s && *s != 'm') while (*s && *s != 'm' && *s != '\x1b') s++;
if (*s == 'm') s++;
continue;
}
if (*s == '\x02' || *s == '\x0f' || *s == '\x16' || *s == '\x1d' || *s == '\x1f') { s++; continue; }
if (*s == '\x03') {
s++;
if (*s >= '0' && *s <= '9') { s++; if (*s >= '0' && *s <= '9') s++; }
if (*s == ',') { s++; if (*s >= '0' && *s <= '9') { s++; if (*s >= '0' && *s <= '9') s++; } }
continue;
}
if (s[0] == '`' && s[1] == '`' && s[2] == '`') {
in_code_block = !in_code_block;
s += 3;
if (*s == '\n') s++;
continue;
}
if (in_code_block) { *r++ = *s++; continue; }
if (*s == '`') { s++; continue; }
if (s[0] == '*' && s[1] == '*') {
s += 2;
while (*s && !(s[0] == '*' && s[1] == '*')) s++;
if (*s) s += 2;
continue;
}
if (*s == '*' && s[1] && s[1] != '*') {
s++;
while (*s && *s != '*') s++;
if (*s == '*') s++;
continue;
}
if (*s == '[') {
char *rb = strchr(s, ']');
if (rb && *(rb+1) == '(') {
char *lp = strchr(rb+2, ')');
if (lp) { s++; while (s < rb) *r++ = *s++; s = lp + 1; continue; }
}
}
*r++ = *s++;
}
*r = 0;
char *src = start, *dst = start;
int nlc = 0;
while (*src) {
if (*src == '\n') { nlc++; if (nlc > 2) { src++; continue; } }
else nlc = 0;
*dst++ = *src++;
}
*dst = 0;
src = start; dst = start;
while (*src) {
if (*src == ' ' && *(src+1) == ' ') { src++; continue; }
*dst++ = *src++;
}
*dst = 0;
}
/*
* has_crlf - Check if a string contains CR or LF characters.
*
* Used as a security check to prevent IRC protocol injection via
* embedded newlines in user-provided data.
*
* Parameters:
* s - The string to check (may be NULL).
*
* Returns: 1 if \r or \n is found, 0 if not found or s is NULL.
*/
int has_crlf(const char *s) {
return s && strpbrk(s, "\r\n") != NULL;
}
/*
* has_ctl - Check if a string contains ASCII control characters.
*
* Scans for bytes below 0x20 (excluding common whitespace) or 0x7f (DEL).
* Used to validate IRC inputs and reject strings with embedded control
* codes that could disrupt the protocol.
*
* Parameters:
* s - The string to check (may be NULL).
*
* Returns: 1 if a control character is found, 0 otherwise.
*/
int has_ctl(const char *s) {
if (!s) return 0;
for (const unsigned char *p = (const unsigned char*)s; *p; p++)
if (*p < 0x20 || *p == 0x7f) return 1;
return 0;
}
/*
* normalize_city - Normalize a user-provided city name for display and URLs.
*
* Produces two output forms: a display form with spaces, and a URL form
* with '+' replacing spaces. Strips non-alphanumeric characters except
* dots, commas, hyphens, and underscores. Collapses multiple spaces into
* single spaces. Defaults to "Berlin" if the input is empty or results
* in an empty string after sanitization.
*
* Parameters:
* in - The raw city name from the user.
* display - Output buffer for the display form (e.g. "New York").
* dsz - Size of the display buffer.
* url - Output buffer for the URL form (e.g. "New+York").
* usz - Size of the URL buffer.
*
* Returns: void.
*/
void normalize_city(const char *in, char *display, size_t dsz, char *url, size_t usz) {
size_t dl = 0, ul = 0;
int pending_space = 0;
if (!in || !*in) in = "Berlin";
for (const unsigned char *p = (const unsigned char *)in; *p; p++) {
int word = isalnum(*p) || *p == '.' || *p == ',' || *p == '-' || *p == '_';
int space = *p == ' ' || *p == '\t' || *p == '+';
if (!word && !space) continue;
if (space) {
pending_space = dl > 0;
continue;
}
if (pending_space) {
if (dl + 1 < dsz) display[dl++] = ' ';
if (ul + 1 < usz) url[ul++] = '+';
pending_space = 0;
}
if (dl + 1 < dsz) display[dl++] = *p;
if (ul + 1 < usz) url[ul++] = *p;
}
if (dl == 0) {
snprintf(display, dsz, "Berlin");
snprintf(url, usz, "Berlin");
} else {
display[dl] = 0;
url[ul] = 0;
}
}
/*
* collapse_spaces - Collapse runs of whitespace into single spaces.
*
* Modifies a string in place, replacing each run of one or more spaces
* with a single space. Leading and trailing spaces are also removed.
* No-op if the string is NULL or empty.
*
* Parameters:
* s - The string to modify in place.
*
* Returns: void.
*/
void collapse_spaces(char *s) {
char *r = s, *w = s;
int in_space = 1;
while (*r) {
if (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') {
if (!in_space) *w++ = ' ';
in_space = 1;
} else {
*w++ = *r;
in_space = 0;
}
r++;
}
*w = 0;
while (w > s && *(w-1) == ' ') *--w = 0;
}
/* ---- curl helper ---- */
/*
* run_curl - Execute curl to fetch a URL and capture stdout.
*
* Forks a child process that exec's curl with a 10-second timeout,
* suppresses stderr to /dev/null, and uses a fake Firefox User-Agent.
* The parent reads up to 32KB of output from the pipe, then waits for
* the child to exit. Used as a lightweight HTTP client without linking
* libcurl.
*
* Parameters:
* url - The URL to fetch.
*
* Returns: A newly allocated string with the response body on success,
* or NULL on failure. Caller must free() the result.
*/
static char *run_curl(const char *url) {
int fd[2];
if (pipe(fd) < 0) return NULL;
pid_t pid = fork();
if (pid < 0) { close(fd[0]); close(fd[1]); return NULL; }
if (pid == 0) {
close(fd[0]);
dup2(fd[1], STDOUT_FILENO);
close(fd[1]);
int dn = open("/dev/null", O_WRONLY);
if (dn >= 0) { dup2(dn, STDERR_FILENO); close(dn); }
execlp("curl", "curl", "-s", "--max-time", "10", "-A", "zynk/" VERSION, url, (char *)NULL);
_exit(127);
}
close(fd[1]);
char buf[32768], tmp[4096];
size_t len = 0;
ssize_t n;
while ((n = read(fd[0], tmp, sizeof tmp)) > 0) {
size_t avail = sizeof buf - 1 - len;
if (avail > 0) {
size_t copy = (size_t)n < avail ? (size_t)n : avail;
memcpy(buf + len, tmp, copy);
len += copy;
}
}
close(fd[0]);
int status;
waitpid(pid, &status, 0);
buf[len] = 0;
return status == 0 ? strdup(buf) : NULL;
}
/*
* fetch_url - Fetch content from a URL via curl.
*
* Thin wrapper around run_curl() for semantic clarity. Used by weather,
* forecast, and stock commands to fetch data from external APIs.
*
* Parameters:
* url - The URL to fetch.
*
* Returns: A newly allocated string with the response body, or NULL on
* failure. Caller must free() the result.
*/
char *fetch_url(const char *url) { return run_curl(url); }
/*
* try_compile_and_restart - Attempt to compile zynk and restart if successful.
*
* Runs "make" (with stderr redirected to /tmp/zynk_make_err). If compilation
* succeeds, notifies the IRC channel and triggers a hot reload via reload_do().
* If compilation fails, reads the first line of the error output and sends it
* to IRC as a notification. Used after code-change AI commands to auto-deploy.
*
* Parameters:
* s - The IRC session.
* target - The channel or nick to send compilation status to.
*
* Returns: 1 if the restart was triggered, 0 if compilation failed.
*/
int try_compile_and_restart(Session *s, const char *target) {
log_stamp(); fprintf(stderr, CLR_GREEN "Code change detected, compiling..." CLR_RESET "\n");
int efd = secure_state_open("/tmp/zynk_make_err", O_WRONLY | O_CREAT | O_TRUNC);
if (efd < 0) {
irc_msg(s, target, "Cannot create secure build log");
return 0;
}
close(efd);
int status = system("make 2>/tmp/zynk_make_err");
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
irc_msg(s, target, "Code compiled successfully, restarting...");
log_stamp(); fprintf(stderr, CLR_GREEN "Compilation successful, restarting..." CLR_RESET "\n");
int cfd = secure_state_open("/tmp/zynk_code_restart", O_WRONLY | O_CREAT | O_TRUNC);
FILE *cf = cfd >= 0 ? fdopen(cfd, "w") : NULL;
if (cf) { fprintf(cf, "%s\n", target); fclose(cf); }
reload_do(s, target, 1);
return 1;
} else {
char errbuf[512];
efd = secure_state_open("/tmp/zynk_make_err", O_RDONLY);
FILE *f = efd >= 0 ? fdopen(efd, "r") : NULL;
if (f) {
size_t n = fread(errbuf, 1, sizeof errbuf - 1, f);
errbuf[n] = 0;
fclose(f);
char *nl = strchr(errbuf, '\n');
if (nl) *nl = 0;
} else {
snprintf(errbuf, sizeof errbuf, "unknown error");
}
char msg[640];
snprintf(msg, sizeof msg, "Compilation failed: %s", errbuf);
irc_msg(s, target, msg);
log_stamp(); fprintf(stderr, CLR_RED "Compilation failed:" CLR_RESET " %s\n", errbuf);
return 0;
}
}
/*
* json_find - Locate a key in a flat JSON string and return a pointer to its value.
*
* Searches for the first occurrence of key in haystack using strstr(), then
* skips past the key, any whitespace, an optional colon, more whitespace, and
* any leading '[' or space characters to return a pointer to the value. This
* is a simple helper for parsing flat JSON responses (e.g. from Yahoo Finance
* or YouTube oEmbed APIs) without a full JSON parser. Not suitable for nested
* or complex JSON structures.
*
* Parameters:
* haystack - The JSON string to search (must be NUL-terminated).
* key - The JSON key to find, including quotes (e.g. "\"title\":").
*
* Returns: A pointer into haystack at the start of the value, or NULL if the
* key is not found.
*/
const char *json_find(const char *haystack, const char *key) {
const char *p = strstr(haystack, key);
if (!p) return NULL;
p += strlen(key);
while (*p == ' ' || *p == '\t') p++;
if (*p == ':') { p++; while (*p == ' ' || *p == '\t') p++; }
while (*p == '[' || *p == ' ') p++;
return p;
}
#ifdef HAS_CMD_ZYNK
#include "cmd_zynk.h"
#endif
#ifdef HAS_CMD_PING
#include "cmd_ping.h"
#endif
#ifdef HAS_CMD_VERSION
#include "cmd_version.h"
#endif
#ifdef HAS_CMD_HELP
#include "cmd_help.h"
#endif
#ifdef HAS_CMD_QUIT
#include "cmd_quit.h"
#endif
#ifdef HAS_CMD_WEATHER
#include "cmd_weather.h"
#endif
#ifdef HAS_CMD_FORECAST
#include "cmd_forecast.h"
#endif
#ifdef HAS_CMD_STOCK
#include "cmd_stock.h"
#endif
#ifdef HAS_CMD_CALC
#include "cmd_calc.h"
#endif
#ifdef HAS_CMD_TIME
#include "cmd_time.h"
#endif
#ifdef HAS_CMD_SEEN
#include "cmd_seen.h"
#endif
#ifdef HAS_CMD_TELL
#include "cmd_tell.h"
#endif
#ifdef HAS_CMD_GITLOG
#include "cmd_gitlog.h"
#endif
#ifdef HAS_CMD_CHANGELOG
#include "cmd_changelog.h"
#endif
#ifdef HAS_CMD_AI
#include "cmd_ai.h"
#endif
#ifdef HAS_CMD_CODE
#include "cmd_code.h"
#endif
#ifdef HAS_CMD_REBUILD
#include "cmd_rebuild.h"
#endif
#ifdef HAS_CMD_UPTIME
#include "cmd_uptime.h"
#endif
#ifdef HAS_CMD_RELOAD
#include "cmd_reload.h"
#endif
#ifdef HAS_CMD_RESTART
#include "cmd_restart.h"
#endif
#ifdef HAS_CMD_YT
#include "cmd_yt.h"
#endif
#ifdef HAS_CMD_WP
#include "cmd_wp.h"
#endif
#ifdef HAS_CMD_GREETING_OR_CHAT
#include "cmd_greeting_or_chat.h"
#endif
#include "irc.h"
/* ---- usage ---- */
/*
* usage - Print command-line usage information to stderr.
*
* Displays all available command-line options (-ssl, -server, -port, -pass,
* -nick, -join, -opencode, -noverify, -h) with descriptions and defaults.
* Also lists the basic zynk store/lookup commands. Called when -h/--help
* is passed or when invalid arguments are detected.
*
* Parameters:
* prog - The program name (argv[0]).
*
* Returns: void (output goes to stderr).
*/
static void usage(const char *prog) {
fprintf(stderr,
"Usage: %s [-ssl] [-server host] [-port p] [-nick n] [-join c] [-opencode path]\n"
" -h This help\n"
" -noverify Skip TLS certificate verification\n"
" -server host IRC server (default: %s)\n"
" -port p Port (default: %d)\n"
" -pass p Server password\n"
" -nick n Nickname (default: zynk<PID>)\n"
" -join c Auto-join channels (comma-sep, default: #zynk)\n"
" -opencode p Path to opencode binary (default: %s)\n"
"\n"
"Zynk commands:\n"
" !zynk foo = bar Store 'bar' under key 'foo'\n"
" !zynk foo Retrieve value for 'foo'\n",
prog, DEFAULT_SERVER, DEF_PORT, OPENCODE_BIN);
}
/* ---- reconnect ---- */
/*
* net_reconnect - Attempt to reconnect to the IRC server after disconnection.
*
* Cleans up the existing TLS session and socket, then tries up to 10 times
* to reconnect with increasing delays (5s, 7s, 9s, ...). For each attempt,
* creates a new TCP connection, performs TLS handshake if enabled, sets
* non-blocking mode, sends PASS/NICK/USER to re-authenticate. Logs each
* attempt and gives up after 10 failures. Used by the main loop when a
* read returns 0 (clean disconnect) or a fatal error.
*
* Parameters:
* s - The IRC session to reconnect (host, port, nick, etc. are reused).
*
* Returns: 0 on successful reconnection, -1 after exhausting all attempts.
*/
static int net_reconnect(Session *s) {
tls_cleanup(s);
if (s->fd >= 0) close(s->fd);
s->fd = -1;
s->connected = 0;
s->rlen = 0;
for (int attempt = 0; attempt < 10; attempt++) {
if (attempt > 0) {
log_stamp(); fprintf(stderr, CLR_YELLOW "Reconnecting in %ds (attempt %d/10)..." CLR_RESET "\n",
5 + attempt * 2, attempt + 1);
sleep(5 + attempt * 2);
}
s->fd = net_connect(s->host, s->port);
if (s->fd < 0) {
log_stamp(); fprintf(stderr, CLR_RED "Reconnect attempt %d/10: connect failed" CLR_RESET "\n", attempt + 1);
continue;
}
if (s->use_tls && tls_handshake(s) < 0) {
log_stamp(); fprintf(stderr, CLR_RED "Reconnect attempt %d/10: TLS failed" CLR_RESET "\n", attempt + 1);
close(s->fd); s->fd = -1;
continue;
}
int fl = fcntl(s->fd, F_GETFL, 0);
fcntl(s->fd, F_SETFL, fl | O_NONBLOCK);
if (s->pass[0]) net_send(s, "PASS %s\r\n", s->pass);
irc_nick(s, s->nick);
net_send(s, "USER %s 0 * :%s\r\n", s->user, s->real);
log_stamp(); fprintf(stderr, CLR_GREEN "Reconnected to %s:%d" CLR_RESET "\n", s->host, s->port);
return 0;
}
log_stamp(); fprintf(stderr, CLR_RED "Giving up after 10 reconnect attempts" CLR_RESET "\n");
return -1;
}
/* ---- main ---- */
/*
* main - Entry point: parse arguments, initialize, connect, and run the event loop.
*
* Parses command-line arguments (server, port, nick, channels, TLS options,
* opencode path). Validates inputs (IRC-safe strings, valid port, etc.).
* Initializes the SQLite database. Attempts hot-reload restoration if TLS
* is enabled; otherwise establishes a fresh connection with TLS handshake.
* Sets up signal handlers (SIGINT/SIGTERM for graceful shutdown, SIGPIPE
* ignored). Runs the main event loop using poll() with a 100ms timeout:
* reads incoming IRC data and feeds it through irc_feed/irc_handle, and
* checks for completed AI child processes via ai_check_completion(). On
* shutdown, kills any pending AI process, sends QUIT, cleans up TLS/DB,
* and exits cleanly.
*
* Parameters:
* argc - Argument count.
* argv - Argument vector.
*
* Returns: 0 on clean shutdown, 1 on error.
*/
int main(int argc, char **argv) {
const char *sv = DEFAULT_SERVER;
int use_tls = 0;
int port = DEF_PORT;
char nickbuf[32];
const char *nick = NULL;
char passbuf[256] = "";
char joinbuf[256] = "#zynk";
g_opencode_bin = OPENCODE_BIN;
int pos = 0;
log_stamp(); fprintf(stderr, CLR_CYAN "[zynk (" VERSION ")] starting up" CLR_RESET "\n");
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
usage(argv[0]); return 0;
}
if (argv[i][0] == '-') {
if (strcmp(argv[i], "-ssl") == 0 || strcmp(argv[i], "-tls") == 0)
use_tls = 1;
else if (strcmp(argv[i], "-pass") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
snprintf(passbuf, sizeof passbuf, "%s", argv[i]);
} else if (strcmp(argv[i], "-nick") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
nick = argv[i];
} else if (strcmp(argv[i], "-port") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
port = atoi(argv[i]);
} else if (strcmp(argv[i], "-server") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
sv = argv[i];
} else if (strcmp(argv[i], "-join") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
snprintf(joinbuf, sizeof joinbuf, "%s", argv[i]);
} else if (strcmp(argv[i], "-opencode") == 0) {
if (++i >= argc) { usage(argv[0]); return 1; }
g_opencode_bin = argv[i];
} else if (strcmp(argv[i], "-noverify") == 0) {
tls_verify = 0;
} else {
usage(argv[0]); return 1;
}
} else switch (pos++) {
case 0: sv = argv[i]; break;
case 1: {
const char *p = argv[i];
if (p[0] == '+') { use_tls = 1; p++; }
port = atoi(p);
break;
}
case 2: nick = argv[i]; break;
default:
fprintf(stderr, CLR_RED "Unexpected arg:" CLR_RESET " %s\n", argv[i]);
return 1;
}
}
if (port <= 0 || port > 65535) port = DEF_PORT;
if ((port == 6697 || port == 7000) && !use_tls) use_tls = 1;
if (!nick) { snprintf(nickbuf, sizeof nickbuf, "zynk"); nick = nickbuf; }
if (!valid_irc_word(nick)) {
fprintf(stderr, CLR_RED "Invalid nick" CLR_RESET "\n");
return 1;
}
if (has_crlf(passbuf) || has_crlf(joinbuf) || has_ctl(passbuf)) {
fprintf(stderr, CLR_RED "Invalid IRC argument" CLR_RESET "\n");
return 1;
}
if (!g_opencode_bin || !*g_opencode_bin) {
fprintf(stderr, CLR_RED "Invalid opencode path" CLR_RESET "\n");
return 1;
}
if (has_ctl(g_opencode_bin)) {
fprintf(stderr, CLR_RED "Invalid opencode path (control chars)" CLR_RESET "\n");
return 1;
}
if (db_init() < 0) {
fprintf(stderr, CLR_RED "DB init failed" CLR_RESET "\n");
sqlite_cleanup();
return 1;
}
start_time = time(NULL);
Session s;
memset(&s, 0, sizeof s);
snprintf(s.host, sizeof s.host, "%s", sv);
s.port = port;
snprintf(s.nick, sizeof s.nick, "%s", nick);
snprintf(s.user, sizeof s.user, "%s", nick);
snprintf(s.real, sizeof s.real, "zynk");
snprintf(s.auto_join, sizeof s.auto_join, "%s", joinbuf);
s.fd = -1;
s.use_tls = use_tls;
if (passbuf[0]) snprintf(s.pass, sizeof s.pass, "%s", passbuf);
signal(SIGINT, handle_sigterm);
signal(SIGTERM, handle_sigterm);
signal(SIGPIPE, SIG_IGN);
if (use_tls) reload_try_restore(&s);
{
if (use_tls && tls_init() < 0) {
fprintf(stderr, CLR_RED "TLS init failed" CLR_RESET "\n");
db_close_cleanup();
sqlite_cleanup();
return 1;
}
s.fd = net_connect(s.host, s.port);
if (s.fd < 0) {
fprintf(stderr, CLR_RED "Could not connect to %s:%d" CLR_RESET "\n", s.host, s.port);
tls_ctx_cleanup();
db_close_cleanup();
sqlite_cleanup();
return 1;
}
if (s.use_tls && tls_handshake(&s) < 0) {
fprintf(stderr, CLR_RED "TLS handshake failed" CLR_RESET "\n");
close(s.fd);
tls_ctx_cleanup();
db_close_cleanup();
sqlite_cleanup();
return 1;
}
int fl = fcntl(s.fd, F_GETFL, 0);
fcntl(s.fd, F_SETFL, fl | O_NONBLOCK);
log_stamp(); fprintf(stderr, CLR_GREEN "Connecting to %s:%d as %s..." CLR_RESET "\n", s.host, s.port, s.nick);
if (s.pass[0]) net_send(&s, "PASS %s\r\n", s.pass);
irc_nick(&s, s.nick);
net_send(&s, "USER %s 0 * :%s\r\n", s.user, s.real);
}
running = 1;
pending_ai_pid = 0;
current_userhost[0] = 0;
while (running) {
struct pollfd pfd = { .fd = s.fd, .events = POLLIN };
int ret = poll(&pfd, 1, POLL_MS);
if (ret < 0) { if (errno == EINTR) continue; break; }
if (pfd.revents & (POLLIN | POLLHUP)) {
char buf[4096];
int n = net_read(&s, buf, sizeof buf);
if (n > 0) { s.poll_lines_left = MAX_LINES_PER_POLL; irc_feed(&s, buf, n); }
else if (n == 0 || (n < 0 && errno != EAGAIN && errno != EINTR)) {
log_stamp(); fprintf(stderr, CLR_YELLOW "Disconnected" CLR_RESET "\n");
s.connected = 0;
if (net_reconnect(&s) < 0) break;
continue;
}
}
ai_check_completion(&s);
}
if (pending_ai_pid > 0) {
kill(pending_ai_pid, SIGTERM);
waitpid(pending_ai_pid, NULL, 0);
ai_reset(pending_ai_id);
pending_ai_pid = 0;
}
if (s.connected && s.fd >= 0) {
int old_fl = fcntl(s.fd, F_GETFL, 0);
fcntl(s.fd, F_SETFL, old_fl & ~O_NONBLOCK);
net_send(&s, "QUIT :zynk\r\n");
struct timespec ts = { .tv_sec = 0, .tv_nsec = 500000000 };
nanosleep(&ts, NULL);
}
tls_cleanup(&s);
tls_ctx_cleanup();
if (s.fd >= 0) close(s.fd);
db_close_cleanup();
sqlite_cleanup();
return 0;
}