aboutsummaryrefslogtreecommitdiff
path: root/xmppcb.c
diff options
context:
space:
mode:
Diffstat (limited to 'xmppcb.c')
-rw-r--r--xmppcb.c1190
1 files changed, 1190 insertions, 0 deletions
diff --git a/xmppcb.c b/xmppcb.c
new file mode 100644
index 0000000..c934550
--- /dev/null
+++ b/xmppcb.c
@@ -0,0 +1,1190 @@
+/* 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 <ctype.h>
+#include <errno.h>
+#include <netdb.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+#include <unistd.h>
+#include <sys/socket.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#include <openssl/err.h>
+#include <openssl/ssl.h>
+#include <openssl/x509v3.h>
+
+#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 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 = "&amp;"; break;
+ case '<': r = "&lt;"; break;
+ case '>': r = "&gt;"; break;
+ case '"': r = "&quot;"; break;
+ case '\'': r = "&apos;"; 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, "&amp;", 5)) { dst[o++] = '&'; src += 5; continue; }
+ if (!strncmp(src, "&lt;", 4)) { dst[o++] = '<'; src += 4; continue; }
+ if (!strncmp(src, "&gt;", 4)) { dst[o++] = '>'; src += 4; continue; }
+ if (!strncmp(src, "&quot;", 6)) { dst[o++] = '"'; src += 6; continue; }
+ if (!strncmp(src, "&apos;", 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 <?xml?> decl,
+ * the <stream:stream> 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] == '?') { /* <?xml ... ?> */
+ 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 <name>...</name> 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, "</%s", name);
+ const char *ce = strstr(txt, close);
+ if (!ce) return 0;
+ size_t len = (size_t)(ce - txt);
+ if (len >= 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) {
+ return
+ "Commands:\n"
+ " " CMD_PREFIX " summarize [period] - summarise the chat\n"
+ " " CMD_PREFIX " tasks [period] - extract action items\n"
+ " " CMD_PREFIX " <question> - ask anything\n"
+ "\n"
+ " Period: 1h 1d 2d 1w 2w 1m (default: last 50 messages)";
+}
+
+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,
+ "<message to='%s' type='groupchat'><body>%s</body></message>",
+ 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());
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* 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, "<delay")) return; /* history replay */
+
+ char from[384];
+ if (!attr_get(st, "from", from, sizeof from)) return;
+ char *slash = strchr(from, '/');
+ if (!slash || !slash[1]) return; /* room-level msg */
+ *slash = '\0';
+ const char *room = from;
+ const char *nick = slash + 1;
+ if (strcmp(nick, XMPP_NICK) == 0) return; /* our own message */
+
+ char raw[BODYBUF];
+ if (!elem_text(st, "body", raw, sizeof raw) || !raw[0]) return;
+ char bdy[BODYBUF];
+ xml_unescape(bdy, sizeof bdy, raw);
+
+ history_append(room, nick, bdy);
+ handle_command(c, room, bdy);
+
+ } else if (strcmp(tag, "iq") == 0) {
+ if (strstr(st, "urn:xmpp:ping")) { /* answer pings */
+ char id[64], from[384];
+ attr_get(st, "id", id, sizeof id);
+ attr_get(st, "from", from, sizeof from);
+ char reply[512];
+ snprintf(reply, sizeof reply,
+ "<iq type='result' to='%s' id='%s'/>", from, id);
+ conn_puts(c, reply);
+ }
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* XMPP session */
+/* ------------------------------------------------------------------ */
+
+static void send_stream_header(Conn *c) {
+ char buf[512];
+ snprintf(buf, sizeof buf,
+ "<?xml version='1.0'?>"
+ "<stream:stream xmlns='jabber:client' "
+ "xmlns:stream='http://etherx.jabber.org/streams' "
+ "to='%s' version='1.0'>", 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,
+ "<auth xmlns='urn:ietf:params:xml:ns:xmpp-sasl' "
+ "mechanism='PLAIN'>%s</auth>", 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, "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>");
+ 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,
+ "<iq type='set' id='bind1'>"
+ "<bind xmlns='urn:ietf:params:xml:ns:xmpp-bind'>"
+ "<resource>xmppcb</resource></bind></iq>");
+ if (!await_tag(&c, "iq")) goto drop_tls;
+
+ /* online, join rooms */
+ conn_puts(&c, "<presence/>");
+ for (int i = 0; i < NROOMS; i++) {
+ char p[512];
+ snprintf(p, sizeof p,
+ "<presence to='%s/%s'>"
+ "<x xmlns='http://jabber.org/protocol/muc'>"
+ "<history maxstanzas='0'/></x></presence>",
+ 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, "</", 2) == 0) goto drop_tls;
+ char tag[64];
+ root_tag(stanza, tag, sizeof tag);
+ if (strcmp(tag, "stream:error") == 0) goto drop_tls;
+ dispatch(&c, stanza);
+ }
+ int pr = poll(&pfd, 1, KEEPALIVE_SEC * 1000);
+ if (pr < 0) {
+ if (errno == EINTR) continue;
+ goto drop_tls;
+ }
+ if (pr == 0) { /* keepalive */
+ if (conn_write(&c, " ", 1) < 0) goto drop_tls;
+ continue;
+ }
+ if (inlen >= 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);
+ }
+}