/* xmppcb - a lean XMPP AI chatbot in C. * * Joins MUC rooms, logs messages, and answers @bot commands via an * OpenAI-compatible LLM HTTP API. Transport is TLS (STARTTLS for XMPP, * HTTPS for the LLM); there is no end-to-end / OMEMO encryption. * * One dependency: OpenSSL. Everything else is hand-rolled. */ #ifndef _POSIX_C_SOURCE #define _POSIX_C_SOURCE 200809L #endif #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "config.h" #define NROOMS ((int)(sizeof(ROOMS) / sizeof(ROOMS[0]))) /* buffer sizes */ #define INBUF 65536 #define STANZABUF 66000 #define BODYBUF 16384 #define CTXBUF 24576 #define PROMPTBUF 32768 #define INSTRBUF 8192 #define ESCBUF 98304 #define JSONBUF 131072 #define REQBUF 140000 #define HTTPBUF 98304 #define LLMOUT 8192 #define CMDOUT 4096 #define CTX_FILEBUF 131072 #define CTX_MAXLINES 4000 /* run_session() return codes */ enum { SESS_RETRY = 0, SESS_AUTHFAIL = 1 }; /* ------------------------------------------------------------------ */ /* globals */ /* ------------------------------------------------------------------ */ static SSL_CTX *g_ctx; static const char *g_password; static const char *g_apikey; static char g_domain[256]; static char g_localpart[128]; static char g_instruction[INSTRBUF]; static char inbuf[INBUF]; static size_t inlen; static char stanza[STANZABUF]; typedef struct { int fd; SSL *ssl; } Conn; static void die(const char *msg) { fprintf(stderr, "xmppcb: %s\n", msg); exit(1); } /* ------------------------------------------------------------------ */ /* small utilities */ /* ------------------------------------------------------------------ */ /* case-insensitive substring search */ static const char *ci_strstr(const char *hay, const char *needle) { size_t nl = strlen(needle); if (!nl) return hay; for (; *hay; hay++) if (strncasecmp(hay, needle, nl) == 0) return hay; return NULL; } static size_t utf8_encode(char *d, size_t dsz, unsigned cp) { if (cp < 0x80) { if (dsz < 1) return 0; d[0] = (char)cp; return 1; } if (cp < 0x800) { if (dsz < 2) return 0; d[0] = (char)(0xC0 | (cp >> 6)); d[1] = (char)(0x80 | (cp & 0x3F)); return 2; } if (cp < 0x10000) { if (dsz < 3) return 0; d[0] = (char)(0xE0 | (cp >> 12)); d[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); d[2] = (char)(0x80 | (cp & 0x3F)); return 3; } if (dsz < 4) return 0; d[0] = (char)(0xF0 | (cp >> 18)); d[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); d[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); d[3] = (char)(0x80 | (cp & 0x3F)); return 4; } static const char B64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; static size_t b64encode(char *dst, const unsigned char *src, size_t n) { size_t o = 0, i = 0; while (i + 3 <= n) { unsigned v = (src[i] << 16) | (src[i + 1] << 8) | src[i + 2]; dst[o++] = B64[(v >> 18) & 63]; dst[o++] = B64[(v >> 12) & 63]; dst[o++] = B64[(v >> 6) & 63]; dst[o++] = B64[v & 63]; i += 3; } if (n - i == 1) { unsigned v = src[i] << 16; dst[o++] = B64[(v >> 18) & 63]; dst[o++] = B64[(v >> 12) & 63]; dst[o++] = '='; dst[o++] = '='; } else if (n - i == 2) { unsigned v = (src[i] << 16) | (src[i + 1] << 8); dst[o++] = B64[(v >> 18) & 63]; dst[o++] = B64[(v >> 12) & 63]; dst[o++] = B64[(v >> 6) & 63]; dst[o++] = '='; } dst[o] = '\0'; return o; } /* escape text for inclusion in XML element/attribute content */ static void xml_escape(char *dst, size_t dsz, const char *src) { size_t o = 0; for (; *src; src++) { const char *r = NULL; switch (*src) { case '&': r = "&"; break; case '<': r = "<"; break; case '>': r = ">"; break; case '"': r = """; break; case '\'': r = "'"; break; } if (r) { size_t l = strlen(r); if (o + l >= dsz) break; memcpy(dst + o, r, l); o += l; } else { if (o + 1 >= dsz) break; dst[o++] = *src; } } dst[o] = '\0'; } /* resolve XML entities in src into dst */ static void xml_unescape(char *dst, size_t dsz, const char *src) { size_t o = 0; while (*src && o + 1 < dsz) { if (*src == '&') { if (!strncmp(src, "&", 5)) { dst[o++] = '&'; src += 5; continue; } if (!strncmp(src, "<", 4)) { dst[o++] = '<'; src += 4; continue; } if (!strncmp(src, ">", 4)) { dst[o++] = '>'; src += 4; continue; } if (!strncmp(src, """, 6)) { dst[o++] = '"'; src += 6; continue; } if (!strncmp(src, "'", 6)) { dst[o++] = '\''; src += 6; continue; } if (src[1] == '#') { int base = 10; const char *q = src + 2; if (*q == 'x' || *q == 'X') { base = 16; q++; } char *endp; long cp = strtol(q, &endp, base); if (endp != q && *endp == ';' && cp > 0) { o += utf8_encode(dst + o, dsz - 1 - o, (unsigned)cp); src = endp + 1; continue; } } } dst[o++] = *src++; } dst[o] = '\0'; } /* escape a string for use as a JSON string value */ static void json_escape(char *dst, size_t dsz, const char *src) { size_t o = 0; for (; *src; src++) { unsigned char ch = (unsigned char)*src; char tmp[8]; const char *r; size_t rl; switch (ch) { case '"': r = "\\\""; rl = 2; break; case '\\': r = "\\\\"; rl = 2; break; case '\n': r = "\\n"; rl = 2; break; case '\r': r = "\\r"; rl = 2; break; case '\t': r = "\\t"; rl = 2; break; case '\b': r = "\\b"; rl = 2; break; case '\f': r = "\\f"; rl = 2; break; default: if (ch < 0x20) { snprintf(tmp, sizeof tmp, "\\u%04x", ch); r = tmp; rl = 6; } else { tmp[0] = (char)ch; r = tmp; rl = 1; } } if (o + rl >= dsz) break; memcpy(dst + o, r, rl); o += rl; } dst[o] = '\0'; } /* extract a JSON string value for "key" from json into out */ static int json_get_string(const char *json, const char *key, char *out, size_t osz) { char pat[64]; snprintf(pat, sizeof pat, "\"%s\"", key); const char *p = strstr(json, pat); if (!p) return 0; p += strlen(pat); while (*p && *p != ':') p++; if (*p != ':') return 0; p++; while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++; if (*p != '"') return 0; p++; size_t o = 0; while (*p && *p != '"') { if (*p == '\\') { p++; if (!*p) break; switch (*p) { case 'n': if (o + 1 < osz) out[o++] = '\n'; break; case 't': if (o + 1 < osz) out[o++] = '\t'; break; case 'r': if (o + 1 < osz) out[o++] = '\r'; break; case 'b': if (o + 1 < osz) out[o++] = '\b'; break; case 'f': if (o + 1 < osz) out[o++] = '\f'; break; case '/': if (o + 1 < osz) out[o++] = '/'; break; case '"': if (o + 1 < osz) out[o++] = '"'; break; case '\\': if (o + 1 < osz) out[o++] = '\\'; break; case 'u': { unsigned cp = 0; for (int i = 1; i <= 4 && p[i]; i++) { char c = p[i]; cp <<= 4; if (c >= '0' && c <= '9') cp |= (unsigned)(c - '0'); else if (c >= 'a' && c <= 'f') cp |= (unsigned)(c - 'a' + 10); else if (c >= 'A' && c <= 'F') cp |= (unsigned)(c - 'A' + 10); } p += 4; if (cp >= 0xD800 && cp <= 0xDBFF && p[1] == '\\' && p[2] == 'u') { unsigned lo = 0; for (int i = 3; i <= 6 && p[i]; i++) { char c = p[i]; lo <<= 4; if (c >= '0' && c <= '9') lo |= (unsigned)(c - '0'); else if (c >= 'a' && c <= 'f') lo |= (unsigned)(c - 'a' + 10); else if (c >= 'A' && c <= 'F') lo |= (unsigned)(c - 'A' + 10); } cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); p += 6; } o += utf8_encode(out + o, osz - 1 - o, cp); break; } default: if (o + 1 < osz) out[o++] = *p; } p++; } else { if (o + 1 < osz) out[o++] = *p; p++; } } out[o] = '\0'; return 1; } /* decode HTTP chunked transfer encoding */ static void dechunk(const char *src, char *dst, size_t dsz) { size_t o = 0; while (*src) { char *end; long sz = strtol(src, &end, 16); if (end == src || sz <= 0) break; src = strchr(end, '\n'); if (!src) break; src++; for (long i = 0; i < sz && *src && o + 1 < dsz; i++) dst[o++] = *src++; if (*src == '\r') src++; if (*src == '\n') src++; } dst[o] = '\0'; } static void rtrim(char *s) { size_t n = strlen(s); while (n && isspace((unsigned char)s[n - 1])) s[--n] = '\0'; } /* ------------------------------------------------------------------ */ /* minimal XML stanza parsing */ /* ------------------------------------------------------------------ */ /* Length of the first complete XML chunk in b[0..n): an decl, * the open tag, or a full top-level stanza. 0 if more * data is needed. */ static size_t xml_chunk_len(const char *b, size_t n) { size_t i = 0; while (i < n && (b[i] == ' ' || b[i] == '\t' || b[i] == '\r' || b[i] == '\n')) i++; if (i >= n || b[i] != '<') return 0; if (i + 1 < n && b[i + 1] == '?') { /* */ for (size_t j = i + 2; j + 1 < n; j++) if (b[j] == '?' && b[j + 1] == '>') return j + 2; return 0; } int depth = 0; size_t j = i; while (j < n) { if (b[j] != '<') { j++; continue; } int closing = (j + 1 < n && b[j + 1] == '/'); char q = 0; size_t k = j + 1; while (k < n) { char ch = b[k]; if (q) { if (ch == q) q = 0; } else if (ch == '\'' || ch == '"') q = ch; else if (ch == '>') break; k++; } if (k >= n) return 0; /* tag incomplete */ int selfclose = (b[k - 1] == '/'); if (depth == 0 && !closing && !selfclose && k - (j + 1) >= 13 && memcmp(b + j + 1, "stream:stream", 13) == 0) return k + 1; /* stream open */ if (closing) depth--; else if (!selfclose) depth++; j = k + 1; if (depth <= 0) return j; /* stanza complete */ } return 0; } /* name of the root element of s into out */ static void root_tag(const char *s, char *out, size_t osz) { size_t o = 0; while (*s && *s != '<') s++; if (*s == '<') s++; while (*s && *s != ' ' && *s != '\t' && *s != '\r' && *s != '\n' && *s != '>' && *s != '/' && o + 1 < osz) out[o++] = *s++; out[o] = '\0'; } /* value of attribute `name` from the root tag of stanza */ static int attr_get(const char *st, const char *name, char *out, size_t osz) { out[0] = '\0'; const char *end = st; char q = 0; while (*end) { /* end of root open tag */ if (q) { if (*end == q) q = 0; } else if (*end == '\'' || *end == '"') q = *end; else if (*end == '>') break; end++; } size_t nl = strlen(name); for (const char *p = st; p < end; p++) { if ((*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') && p + 1 + nl < end && strncmp(p + 1, name, nl) == 0) { const char *a = p + 1 + nl; while (a < end && (*a == ' ' || *a == '\t')) a++; if (a < end && *a == '=') { a++; while (a < end && (*a == ' ' || *a == '\t')) a++; if (a < end && (*a == '\'' || *a == '"')) { char qq = *a++; size_t o = 0; while (a < end && *a != qq && o + 1 < osz) out[o++] = *a++; out[o] = '\0'; return 1; } } } } return 0; } /* text content of the first ... child element */ static int elem_text(const char *st, const char *name, char *out, size_t osz) { out[0] = '\0'; size_t nl = strlen(name); const char *p = st; while ((p = strchr(p, '<'))) { if (strncmp(p + 1, name, nl) == 0) { char after = p[1 + nl]; if (after == ' ' || after == '\t' || after == '\r' || after == '\n' || after == '>' || after == '/') { const char *gt = p + 1; char q = 0; while (*gt) { if (q) { if (*gt == q) q = 0; } else if (*gt == '\'' || *gt == '"') q = *gt; else if (*gt == '>') break; gt++; } if (*gt != '>') return 0; if (gt > p && gt[-1] == '/') return 1; /* empty */ const char *txt = gt + 1; char close[64]; snprintf(close, sizeof close, "= osz) len = osz - 1; memcpy(out, txt, len); out[len] = '\0'; return 1; } } p++; } return 0; } /* ------------------------------------------------------------------ */ /* connection: TCP + optional TLS */ /* ------------------------------------------------------------------ */ static int conn_write(Conn *c, const char *buf, size_t len) { size_t off = 0; while (off < len) { int n = c->ssl ? SSL_write(c->ssl, buf + off, (int)(len - off)) : (int)write(c->fd, buf + off, len - off); if (n <= 0) return -1; off += (size_t)n; } return 0; } static int conn_puts(Conn *c, const char *s) { return conn_write(c, s, strlen(s)); } static int conn_read_some(Conn *c, char *buf, size_t len) { return c->ssl ? SSL_read(c->ssl, buf, (int)len) : (int)read(c->fd, buf, len); } static int tcp_connect(const char *host, int port) { char portstr[16]; snprintf(portstr, sizeof portstr, "%d", port); struct addrinfo hints, *res, *rp; memset(&hints, 0, sizeof hints); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; if (getaddrinfo(host, portstr, &hints, &res) != 0) return -1; int fd = -1; for (rp = res; rp; rp = rp->ai_next) { fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); if (fd < 0) continue; if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) break; close(fd); fd = -1; } freeaddrinfo(res); if (fd >= 0) { struct timeval tv = { 60, 0 }; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); } return fd; } static void tls_init(void) { g_ctx = SSL_CTX_new(TLS_client_method()); if (!g_ctx) die("SSL_CTX_new failed"); SSL_CTX_set_min_proto_version(g_ctx, TLS1_2_VERSION); #if TLS_VERIFY if (!SSL_CTX_set_default_verify_paths(g_ctx)) fprintf(stderr, "xmppcb: warning: no system CA certificates\n"); #endif } static SSL *tls_wrap(int fd, const char *host) { SSL *ssl = SSL_new(g_ctx); if (!ssl) return NULL; SSL_set_fd(ssl, fd); SSL_set_tlsext_host_name(ssl, host); #if TLS_VERIFY SSL_set_verify(ssl, SSL_VERIFY_PEER, NULL); X509_VERIFY_PARAM *vp = SSL_get0_param(ssl); X509_VERIFY_PARAM_set_hostflags(vp, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); X509_VERIFY_PARAM_set1_host(vp, host, 0); #endif if (SSL_connect(ssl) != 1) { fprintf(stderr, "xmppcb: TLS handshake with %s failed\n", host); ERR_print_errors_fp(stderr); SSL_free(ssl); return NULL; } return ssl; } /* ------------------------------------------------------------------ */ /* stanza stream reader */ /* ------------------------------------------------------------------ */ /* Pull one complete XML chunk out of inbuf into out. Returns its * length, or 0 if inbuf does not yet hold a full chunk. */ static size_t extract_chunk(char *out, size_t osz) { size_t ws = 0; while (ws < inlen && (inbuf[ws] == ' ' || inbuf[ws] == '\t' || inbuf[ws] == '\r' || inbuf[ws] == '\n')) ws++; if (ws) { memmove(inbuf, inbuf + ws, inlen - ws); inlen -= ws; } size_t L = xml_chunk_len(inbuf, inlen); if (L == 0 || L >= osz) return 0; memcpy(out, inbuf, L); out[L] = '\0'; memmove(inbuf, inbuf + L, inlen - L); inlen -= L; return L; } /* Blocking: read the next XML chunk. Used during login. */ static int next_chunk(Conn *c, char *out, size_t osz) { for (;;) { size_t L = extract_chunk(out, osz); if (L) return (int)L; if (inlen >= sizeof inbuf) return -1; int n = conn_read_some(c, inbuf + inlen, sizeof inbuf - inlen); if (n <= 0) return -1; inlen += (size_t)n; } } /* Read chunks until one whose root element is `want`. */ static int await_tag(Conn *c, const char *want) { char tag[64]; for (int i = 0; i < 50; i++) { if (next_chunk(c, stanza, sizeof stanza) < 0) return 0; root_tag(stanza, tag, sizeof tag); if (strcmp(tag, want) == 0) return 1; if (strcmp(tag, "failure") == 0 || strcmp(tag, "stream:error") == 0) return 0; } return 0; } /* ------------------------------------------------------------------ */ /* per-room chat history */ /* ------------------------------------------------------------------ */ static void history_path(const char *room, char *out, size_t osz) { snprintf(out, osz, "%s/%s.log", HISTORY_DIR, room); for (char *p = out + strlen(HISTORY_DIR) + 1; *p; p++) if (*p == '/') *p = '_'; } static void history_append(const char *room, const char *sender, const char *body) { char path[512]; history_path(room, path, sizeof path); FILE *f = fopen(path, "a"); if (!f) return; fprintf(f, "%ld\t%s\t", (long)time(NULL), sender); for (const char *p = body; *p; p++) fputc((*p == '\n' || *p == '\r' || *p == '\t') ? ' ' : *p, f); fputc('\n', f); fclose(f); } /* Build conversation context for the LLM. If cutoff != 0, includes * messages at or after that time; otherwise the last HISTORY_CONTEXT * messages. Returns 1 if any messages were found. */ static int history_context(const char *room, time_t cutoff, char *out, size_t osz) { static char fb[CTX_FILEBUF]; static char *lines[CTX_MAXLINES]; char path[512]; history_path(room, path, sizeof path); FILE *f = fopen(path, "rb"); if (!f) { out[0] = '\0'; return 0; } fseek(f, 0, SEEK_END); long fsz = ftell(f); long start = 0; if (fsz > CTX_FILEBUF - 1) start = fsz - (CTX_FILEBUF - 1); fseek(f, start, SEEK_SET); size_t got = fread(fb, 1, CTX_FILEBUF - 1, f); fclose(f); fb[got] = '\0'; char *lp = fb; if (start > 0) { /* drop partial first line */ char *nl = strchr(fb, '\n'); if (nl) lp = nl + 1; } int nl = 0; for (char *p = lp; *p && nl < CTX_MAXLINES; ) { lines[nl++] = p; char *e = strchr(p, '\n'); if (!e) break; *e = '\0'; p = e + 1; } int from; if (cutoff == 0) { from = nl > HISTORY_CONTEXT ? nl - HISTORY_CONTEXT : 0; } else { from = nl; for (int i = 0; i < nl; i++) { if (atol(lines[i]) >= (long)cutoff) { from = i; break; } } if (nl - from > 600) from = nl - 600; } if (nl - from <= 0) { out[0] = '\0'; return 0; } size_t o = 0; if (cutoff == 0) { o += (size_t)snprintf(out + o, osz - o, "(last %d messages)\n", nl - from); } else { char tb[32]; time_t cc = cutoff; strftime(tb, sizeof tb, "%Y-%m-%d %H:%M", localtime(&cc)); o += (size_t)snprintf(out + o, osz - o, "(since %s)\n", tb); } for (int i = from; i < nl && o + 1 < osz; i++) { char *l = lines[i]; char *t1 = strchr(l, '\t'); if (!t1) continue; *t1 = '\0'; char *sender = t1 + 1; char *t2 = strchr(sender, '\t'); if (!t2) continue; *t2 = '\0'; char *body = t2 + 1; time_t ts = (time_t)atol(l); char tb[24]; strftime(tb, sizeof tb, "%Y-%m-%d %H:%M", localtime(&ts)); int w = snprintf(out + o, osz - o, "[%s] %s: %s\n", tb, sender, body); if (w < 0 || (size_t)w >= osz - o) { out[o] = '\0'; break; } o += (size_t)w; } out[o] = '\0'; return 1; } /* ------------------------------------------------------------------ */ /* LLM HTTP call */ /* ------------------------------------------------------------------ */ /* POST a prompt to the LLM API. On success returns 1 and the reply is * in out. On failure returns 0 and out holds an error message. */ static int llm_call(const char *user_prompt, char *out, size_t osz) { static char esc_instr[ESCBUF]; static char esc_prompt[ESCBUF]; static char jsonbody[JSONBUF]; static char req[REQBUF]; static char resp[HTTPBUF]; static char httpbody[HTTPBUF]; json_escape(esc_instr, sizeof esc_instr, g_instruction); json_escape(esc_prompt, sizeof esc_prompt, user_prompt); int blen = snprintf(jsonbody, sizeof jsonbody, "{\"model\":\"%s\",\"messages\":[" "{\"role\":\"system\",\"content\":\"%s\"}," "{\"role\":\"user\",\"content\":\"%s\"}]," "\"max_completion_tokens\":%d}", LLM_MODEL, esc_instr, esc_prompt, LLM_MAX_TOKENS); if (blen < 0 || blen >= (int)sizeof jsonbody) { snprintf(out, osz, "Error: request too large"); return 0; } int fd = tcp_connect(LLM_HOST, LLM_PORT); if (fd < 0) { snprintf(out, osz, "Error: cannot reach %s", LLM_HOST); return 0; } SSL *ssl = tls_wrap(fd, LLM_HOST); if (!ssl) { close(fd); snprintf(out, osz, "Error: TLS handshake failed"); return 0; } Conn lc = { fd, ssl }; int rlen = snprintf(req, sizeof req, "POST %s HTTP/1.1\r\n" "Host: %s\r\n" "Authorization: Bearer %s\r\n" "Content-Type: application/json\r\n" "Content-Length: %d\r\n" "Connection: close\r\n" "\r\n%s", LLM_PATH, LLM_HOST, g_apikey, blen, jsonbody); if (rlen < 0 || rlen >= (int)sizeof req || conn_write(&lc, req, (size_t)rlen) < 0) { SSL_free(ssl); close(fd); snprintf(out, osz, "Error: failed to send request"); return 0; } size_t rl = 0; int n; while (rl < sizeof resp - 1 && (n = conn_read_some(&lc, resp + rl, sizeof resp - 1 - rl)) > 0) rl += (size_t)n; resp[rl] = '\0'; SSL_shutdown(ssl); SSL_free(ssl); close(fd); char *hdrend = strstr(resp, "\r\n\r\n"); if (!hdrend) { snprintf(out, osz, "Error: bad HTTP response"); return 0; } *hdrend = '\0'; char *bodyp = hdrend + 4; int status = 0; sscanf(resp, "%*s %d", &status); const char *bd; if (ci_strstr(resp, "transfer-encoding: chunked")) { dechunk(bodyp, httpbody, sizeof httpbody); bd = httpbody; } else { bd = bodyp; } if (status != 200) { char detail[512]; if (!json_get_string(bd, "message", detail, sizeof detail)) snprintf(detail, sizeof detail, "HTTP %d", status); snprintf(out, osz, "Error: LLM API: %s", detail); return 0; } const char *m = strstr(bd, "\"message\""); if (!json_get_string(m ? m : bd, "content", out, osz) || !out[0]) { snprintf(out, osz, "Error: could not parse LLM response"); return 0; } return 1; } /* ------------------------------------------------------------------ */ /* commands */ /* ------------------------------------------------------------------ */ static char g_ctxbuf[CTXBUF]; static char g_prompt[PROMPTBUF]; static char g_reply[LLMOUT]; static char g_out[LLMOUT + 64]; /* Parse a period token (1h/1d/2w/1m). Returns a cutoff time, or 0. */ static time_t parse_period(const char *tok) { if (!tok || !*tok) return 0; long num = 0; int any = 0; const char *p = tok; while (*p >= '0' && *p <= '9') { num = num * 10 + (*p - '0'); p++; any = 1; } if (!any || !p[0] || p[1]) return 0; long secs; switch (*p) { case 'h': secs = 3600; break; case 'd': secs = 86400; break; case 'w': secs = 604800; break; case 'm': secs = 86400L * 30; break; default: return 0; } return time(NULL) - num * secs; } static const char *help_text(void) { static char buf[2048]; int n = snprintf(buf, sizeof buf, "Commands:\n" " " CMD_PREFIX " summarize [period] - summarise the chat\n" " " CMD_PREFIX " tasks [period] - extract action items\n" " " CMD_PREFIX " - ask anything\n" "\n" " Period: 1h 1d 2d 1w 2w 1m (default: last 50 messages)"); /* append the script commands found in CMD_DIR */ DIR *d = opendir(CMD_DIR); if (d) { struct dirent *e; int first = 1; while ((e = readdir(d)) != NULL && n > 0 && n < (int)sizeof buf - 80) { if (e->d_name[0] == '.') continue; char path[512]; struct stat stb; snprintf(path, sizeof path, "%s/%s", CMD_DIR, e->d_name); if (stat(path, &stb) != 0 || !S_ISREG(stb.st_mode)) continue; if (access(path, X_OK) != 0) continue; if (first) { n += snprintf(buf + n, sizeof buf - (size_t)n, "\n\nScript commands:"); first = 0; } n += snprintf(buf + n, sizeof buf - (size_t)n, "\n @%s", e->d_name); } closedir(d); } return buf; } static void muc_send(Conn *c, const char *room, const char *text) { static char esc[ESCBUF]; static char msg[ESCBUF + 256]; xml_escape(esc, sizeof esc, text); snprintf(msg, sizeof msg, "%s", room, esc); conn_puts(c, msg); } static void cmd_summarize(Conn *c, const char *room, time_t cutoff) { if (!history_context(room, cutoff, g_ctxbuf, sizeof g_ctxbuf)) { muc_send(c, room, "No messages found for that time period."); return; } snprintf(g_prompt, sizeof g_prompt, "Summarize this group chat concisely. Include key points " "and decisions:\n\n%s", g_ctxbuf); if (llm_call(g_prompt, g_reply, sizeof g_reply)) { snprintf(g_out, sizeof g_out, "Summary:\n%s", g_reply); muc_send(c, room, g_out); } else { muc_send(c, room, g_reply); } } static void cmd_tasks(Conn *c, const char *room, time_t cutoff) { if (!history_context(room, cutoff, g_ctxbuf, sizeof g_ctxbuf)) { muc_send(c, room, "No messages found for that time period."); return; } snprintf(g_prompt, sizeof g_prompt, "Extract all action items and tasks from this conversation. " "For each task include: who is responsible (if mentioned), " "what needs to be done, and any deadlines mentioned. Be " "concise:\n\n%s", g_ctxbuf); if (llm_call(g_prompt, g_reply, sizeof g_reply)) { snprintf(g_out, sizeof g_out, "Tasks:\n%s", g_reply); muc_send(c, room, g_out); } else { muc_send(c, room, g_reply); } } static void cmd_ask(Conn *c, const char *room, const char *question) { if (!question[0]) { muc_send(c, room, "What would you like to ask?"); return; } history_context(room, 0, g_ctxbuf, sizeof g_ctxbuf); snprintf(g_prompt, sizeof g_prompt, "You are a helpful assistant in a group chat.\n\n" "Recent conversation:\n%s\n\n" "Question: %s\n\n" "Answer concisely and helpfully. If the answer is in the " "conversation, reference it.", g_ctxbuf, question); if (llm_call(g_prompt, g_reply, sizeof g_reply)) muc_send(c, room, g_reply); else muc_send(c, room, g_reply); } /* Parse a @bot command out of a chat message and run it. */ static void handle_command(Conn *c, const char *room, const char *body) { while (*body == ' ' || *body == '\t') body++; size_t pl = strlen(CMD_PREFIX); if (strncasecmp(body, CMD_PREFIX, pl) != 0) return; char rest[4096]; snprintf(rest, sizeof rest, "%s", body + pl); char *r = rest; while (*r == ' ' || *r == '\t') r++; memmove(rest, r, strlen(r) + 1); rtrim(rest); if (!rest[0]) { muc_send(c, room, help_text()); return; } char low[4096]; size_t i; for (i = 0; rest[i]; i++) low[i] = (char)tolower((unsigned char)rest[i]); low[i] = '\0'; /* last whitespace-delimited token of `low` */ int e = (int)strlen(low); int s = e; while (s > 0 && !isspace((unsigned char)low[s - 1])) s--; char lasttok[64]; int ltl = e - s; if (ltl > 63) ltl = 63; memcpy(lasttok, low + s, (size_t)ltl); lasttok[ltl] = '\0'; time_t cutoff = parse_period(lasttok); char cmd[4096]; if (cutoff) { int ce = s; while (ce > 0 && isspace((unsigned char)low[ce - 1])) ce--; memcpy(cmd, low, (size_t)ce); cmd[ce] = '\0'; } else { snprintf(cmd, sizeof cmd, "%s", low); } if (!cmd[0] && !cutoff) { muc_send(c, room, help_text()); return; } if (!strcmp(cmd, "summarize") || !strcmp(cmd, "summary") || !strcmp(cmd, "s")) { cmd_summarize(c, room, cutoff); } else if (!strcmp(cmd, "tasks") || !strcmp(cmd, "task") || !strcmp(cmd, "t")) { cmd_tasks(c, room, cutoff); } else if (!strcmp(cmd, "help")) { muc_send(c, room, help_text()); } else if (cmd[0]) { char q[4096]; if (cutoff) { /* drop the period token */ int re = (int)strlen(rest); int rs = re; while (rs > 0 && !isspace((unsigned char)rest[rs - 1])) rs--; while (rs > 0 && isspace((unsigned char)rest[rs - 1])) rs--; memcpy(q, rest, (size_t)rs); q[rs] = '\0'; } else { snprintf(q, sizeof q, "%s", rest); } cmd_ask(c, room, q); } else { muc_send(c, room, help_text()); } } /* ------------------------------------------------------------------ */ /* @scriptname commands */ /* ------------------------------------------------------------------ */ /* Run `path` with `args` passed as a single argv element - no shell is * involved, so the user's text cannot be interpreted as a command. * Captures combined stdout+stderr into out (NUL-terminated, capped at * outsz). The child is SIGKILLed after CMD_TIMEOUT_SEC seconds. * Returns 1 if the script exited 0, else 0. */ static int run_script(const char *path, const char *args, char *out, size_t outsz) { int pfd[2]; out[0] = '\0'; if (pipe(pfd) != 0) { snprintf(out, outsz, "cmd: pipe failed"); return 0; } pid_t pid = fork(); if (pid < 0) { close(pfd[0]); close(pfd[1]); snprintf(out, outsz, "cmd: fork failed"); return 0; } if (pid == 0) { /* child */ dup2(pfd[1], STDOUT_FILENO); dup2(pfd[1], STDERR_FILENO); close(pfd[0]); close(pfd[1]); if (args && *args) execl(path, path, args, (char *)NULL); else execl(path, path, (char *)NULL); _exit(127); /* exec failed */ } close(pfd[1]); /* parent */ size_t len = 0; time_t deadline = time(NULL) + CMD_TIMEOUT_SEC; int killed = 0, truncated = 0; /* read output until EOF, the buffer fills, or the deadline passes */ for (;;) { fd_set rf; FD_ZERO(&rf); FD_SET(pfd[0], &rf); struct timeval tv = { 1, 0 }; int s = select(pfd[0] + 1, &rf, NULL, NULL, &tv); if (s < 0) { if (errno == EINTR) continue; break; } if (s > 0) { ssize_t r = read(pfd[0], out + len, outsz - 1 - len); if (r > 0) { len += (size_t)r; if (len >= outsz - 1) { truncated = 1; break; } } else { break; /* EOF or error */ } } if (time(NULL) >= deadline) { killed = 1; break; } } out[len] = '\0'; if (truncated && outsz > 32) memcpy(out + outsz - 16, " ...[cut]", 10); /* The child may still be running (timeout, or it outran the buffer). * Kill it, then reap with WNOHANG + a deadline so a child that closed * its output but kept running cannot hang the bot on waitpid(). */ if (killed || truncated) kill(pid, SIGKILL); close(pfd[0]); int status = 0; for (;;) { pid_t w = waitpid(pid, &status, WNOHANG); if (w != 0) break; /* reaped, or error */ if (time(NULL) >= deadline) { kill(pid, SIGKILL); killed = 1; } struct timespec ts = { 0, 50000000 }; /* 50 ms */ nanosleep(&ts, NULL); } rtrim(out); if (killed) { snprintf(out, outsz, "cmd: timed out after %d s", CMD_TIMEOUT_SEC); return 0; } if (!out[0]) snprintf(out, outsz, "(no output)"); return WIFEXITED(status) && WEXITSTATUS(status) == 0; } /* Parse a @scriptname message and run CMD_DIR/, posting its * output back to the room. Unknown commands are ignored silently so the * bot does not react to @-mentions of people. */ static void handle_script_command(Conn *c, const char *room, const char *body) { while (*body == ' ' || *body == '\t') body++; if (*body != '@') return; if (strncasecmp(body, CMD_PREFIX, strlen(CMD_PREFIX)) == 0) return; /* that is the @bot prefix */ body++; /* skip '@' */ /* command name: alphanumerics, '-' and '_' only (no path traversal) */ char name[64]; size_t n = 0; while (body[n] && !isspace((unsigned char)body[n])) { unsigned char ch = (unsigned char)body[n]; if (!isalnum(ch) && ch != '-' && ch != '_') return; if (n >= sizeof name - 1) return; name[n] = (char)ch; n++; } if (n == 0) return; name[n] = '\0'; const char *args = body + n; while (*args == ' ' || *args == '\t') args++; char path[256]; snprintf(path, sizeof path, "%s/%s", CMD_DIR, name); if (access(path, X_OK) != 0) return; /* not a command: stay silent */ fprintf(stderr, "xmppcb: running %s\n", path); static char out[CMDOUT]; run_script(path, args, out, sizeof out); muc_send(c, room, out); } /* ------------------------------------------------------------------ */ /* stanza dispatch */ /* ------------------------------------------------------------------ */ static void dispatch(Conn *c, const char *st) { char tag[64]; root_tag(st, tag, sizeof tag); if (strcmp(tag, "message") == 0) { char type[32]; attr_get(st, "type", type, sizeof type); if (strcmp(type, "groupchat") != 0) return; if (strstr(st, "", from, id); conn_puts(c, reply); } } } /* ------------------------------------------------------------------ */ /* XMPP session */ /* ------------------------------------------------------------------ */ static void send_stream_header(Conn *c) { char buf[512]; snprintf(buf, sizeof buf, "" "", g_domain); conn_puts(c, buf); } static void send_auth(Conn *c) { char plain[1024]; size_t pl = 0; plain[pl++] = '\0'; size_t ll = strlen(g_localpart), pwl = strlen(g_password); memcpy(plain + pl, g_localpart, ll); pl += ll; plain[pl++] = '\0'; memcpy(plain + pl, g_password, pwl); pl += pwl; char b64[2048]; b64encode(b64, (unsigned char *)plain, pl); char msg[2400]; snprintf(msg, sizeof msg, "%s", b64); conn_puts(c, msg); } /* Run one full XMPP session. Returns a SESS_* code. */ static int run_session(void) { inlen = 0; int fd = tcp_connect(XMPP_HOST, XMPP_PORT); if (fd < 0) { fprintf(stderr, "xmppcb: connect to %s:%d failed\n", XMPP_HOST, XMPP_PORT); return SESS_RETRY; } Conn c = { fd, NULL }; /* stream + STARTTLS negotiation (plaintext) */ send_stream_header(&c); if (!await_tag(&c, "stream:features")) goto drop; if (!strstr(stanza, "starttls")) { fprintf(stderr, "xmppcb: server did not offer STARTTLS\n"); goto drop; } conn_puts(&c, ""); if (!await_tag(&c, "proceed")) goto drop; /* upgrade to TLS */ inlen = 0; c.ssl = tls_wrap(fd, g_domain); if (!c.ssl) goto drop; /* restart stream, then SASL PLAIN */ send_stream_header(&c); if (!await_tag(&c, "stream:features")) goto drop_tls; if (!strstr(stanza, "PLAIN")) { fprintf(stderr, "xmppcb: server did not offer SASL PLAIN\n"); goto drop_tls; } send_auth(&c); if (!await_tag(&c, "success")) { fprintf(stderr, "xmppcb: authentication failed\n"); SSL_free(c.ssl); close(fd); return SESS_AUTHFAIL; } /* restart stream, then resource bind */ send_stream_header(&c); if (!await_tag(&c, "stream:features")) goto drop_tls; conn_puts(&c, "" "" "xmppcb"); if (!await_tag(&c, "iq")) goto drop_tls; /* online, join rooms */ conn_puts(&c, ""); for (int i = 0; i < NROOMS; i++) { char p[512]; snprintf(p, sizeof p, "" "" "", ROOMS[i], XMPP_NICK); conn_puts(&c, p); fprintf(stderr, "xmppcb: joining %s\n", ROOMS[i]); } fprintf(stderr, "xmppcb: online as %s\n", XMPP_JID); /* main loop */ struct pollfd pfd = { fd, POLLIN, 0 }; for (;;) { while (extract_chunk(stanza, sizeof stanza) > 0) { if (strncmp(stanza, "= sizeof inbuf) goto drop_tls; int n = conn_read_some(&c, inbuf + inlen, sizeof inbuf - inlen); if (n <= 0) goto drop_tls; inlen += (size_t)n; while (SSL_pending(c.ssl) > 0 && inlen < sizeof inbuf) { n = conn_read_some(&c, inbuf + inlen, sizeof inbuf - inlen); if (n <= 0) break; inlen += (size_t)n; } } drop_tls: if (c.ssl) { SSL_shutdown(c.ssl); SSL_free(c.ssl); } close(fd); return SESS_RETRY; drop: close(fd); return SESS_RETRY; } /* ------------------------------------------------------------------ */ /* main */ /* ------------------------------------------------------------------ */ int main(void) { signal(SIGPIPE, SIG_IGN); const char *at = strchr(XMPP_JID, '@'); if (!at || !at[1]) die("XMPP_JID must be user@domain"); size_t ll = (size_t)(at - XMPP_JID); if (ll == 0) die("XMPP_JID must be user@domain"); if (ll >= sizeof g_localpart) die("JID localpart too long"); memcpy(g_localpart, XMPP_JID, ll); g_localpart[ll] = '\0'; snprintf(g_domain, sizeof g_domain, "%s", at + 1); g_password = getenv("XMPP_PASSWORD"); g_apikey = getenv("LLM_API_KEY"); if (!g_password || !*g_password) die("set the XMPP_PASSWORD environment variable"); if (!g_apikey || !*g_apikey) die("set the LLM_API_KEY environment variable"); FILE *f = fopen(INSTRUCTION_FILE, "rb"); if (f) { size_t n = fread(g_instruction, 1, sizeof g_instruction - 1, f); g_instruction[n] = '\0'; fclose(f); } else { fprintf(stderr, "xmppcb: warning: %s not found, " "using empty system prompt\n", INSTRUCTION_FILE); g_instruction[0] = '\0'; } if (mkdir(HISTORY_DIR, 0700) != 0 && errno != EEXIST) die("cannot create history directory"); tls_init(); for (;;) { int r = run_session(); if (r == SESS_AUTHFAIL) return 1; fprintf(stderr, "xmppcb: disconnected, reconnecting in 5s\n"); sleep(5); } }