From 688779fbc61d48046199d1f45549720e289ce417 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 21 May 2026 18:56:19 +0200 Subject: Added support for user generated shell scripts: - create a director with shell scripts - call them within the chat with @ - four sample scripts --- .gitignore | 1 + README.md | 52 +++++++++++++++++-- cmd/fx | 44 ++++++++++++++++ cmd/todo | 16 ++++++ cmd/uptime | 29 +++++++++++ cmd/weather | 38 ++++++++++++++ config.def.h | 2 + xmppcb.c | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 8 files changed, 342 insertions(+), 6 deletions(-) create mode 100755 cmd/fx create mode 100755 cmd/todo create mode 100755 cmd/uptime create mode 100755 cmd/weather diff --git a/.gitignore b/.gitignore index 5d8ccaa..a04be63 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ xmppcb history/ start instruction.md +cmd/todo.md diff --git a/README.md b/README.md index cbe2f8d..b5fc3fe 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # xmppcb -A lean XMPP AI chatbot in C. It joins MUC rooms, logs messages, and -answers `@bot` commands via an OpenAI-compatible LLM HTTP API. +A lean XMPP AI chatbot in C. It joins MUC rooms, logs messages, +answers `@bot` commands via an OpenAI-compatible LLM HTTP API, and runs +`@scriptname` commands backed by local scripts. A C reimplementation of the Python `bot.py` in the parent directory, written in the suckless spirit: one source file, one library @@ -94,10 +95,52 @@ In a joined room: | `@bot summarize [period]` | Summarise the conversation | | `@bot tasks [period]` | Extract action items | | `@bot ` | Ask anything, using recent chat as context | +| `@ [args]` | Run the script command `cmd/` (see below) | Period tokens: `1h 1d 2d 1w 2w 1m`. Without one, the last 50 messages are used. +## Script commands + +Besides the `@bot` builtins, any executable you drop into the `cmd/` +directory becomes an `@` command — no recompile, no restart. + +Example, `cmd/weather`: + +```sh +#!/bin/sh +# @weather [location] - current weather via wttr.in +loc=$(printf '%s' "${1:-Krakow}" | tr ' ' '+') +curl -fsS --max-time 8 "https://wttr.in/${loc}?format=3" +``` + +```sh +chmod +x cmd/weather +``` + +Then in any joined room `@weather Wroclaw` makes the bot run the script, +capture its output (stdout and stderr) and post it back. `@bot help` +lists the script commands it finds. + +How it works and the safety boundaries: + +- The text after the command name is passed as **one argv element** — + `@weather New York` runs `cmd/weather "New York"`. The bot never hands + it to a shell, so there is no command injection; quote `"$1"` inside + your script and you are safe. +- Command names allow only letters, digits, `-` and `_`, so they cannot + escape the `cmd/` directory. +- A script is SIGKILLed after `CMD_TIMEOUT_SEC` seconds (default 10); + the bot is single-threaded, so it is paused meanwhile. +- Output is capped at ~4 KB; longer output is truncated with `...[cut]`. +- Unknown `@names` are ignored silently, so the bot does not react to + `@mentions` of people. + +Security: anyone in a joined room can run anything in `cmd/`. The +command set is whatever lives in that directory — keep it curated, and +do not let scripts print secrets. `CMD_DIR` and `CMD_TIMEOUT_SEC` are in +`config.h`. Two example scripts ship in `cmd/`: `weather` and `uptime`. + ## Run under a supervisor The bot does not daemonise. Use a process supervisor so it restarts @@ -127,8 +170,8 @@ Prefer an `EnvironmentFile=` with mode 600 over inline secrets. ## Notes and limitations - The bot only sees messages sent while it is in the room. -- LLM calls are blocking: the bot pauses for the few seconds a reply - takes. Adequate for a low-traffic bot. +- LLM calls and script commands are blocking: the bot pauses while a + reply or a `cmd/` script runs. Adequate for a low-traffic bot. - The XML parser is intentionally narrow — it understands only the XMPP stanzas this bot needs, not arbitrary XML. - Resource use is small: ~37 KB binary, a few MB RSS. @@ -144,6 +187,7 @@ xmppcb/ ├── instruction.md system prompt in use (gitignored) ├── start.sample run-script template (committed) ├── start run script exporting the secrets (gitignored) +├── cmd/ @scriptname command scripts (weather, uptime, fx) ├── makefile └── history/ per-room chat logs (generated, gitignored) ``` diff --git a/cmd/fx b/cmd/fx new file mode 100755 index 0000000..407c2d0 --- /dev/null +++ b/cmd/fx @@ -0,0 +1,44 @@ +#!/bin/sh +# @fx - exchange rates between USD, EUR, PLN and THB (no arguments) +# +# One keyless request to open.er-api.com (base USD); the six cross-rates +# are derived from it. JSON is parsed with awk so no 'jq' is needed. + +json=$(curl -fsS --max-time 8 "https://open.er-api.com/v6/latest/USD" \ + | tr -d '\n\r') || { echo "fx: rate lookup failed"; exit 1; } + +case "$json" in + *'"result":"success"'*) ;; + *) echo "fx: rate service returned an error"; exit 1 ;; +esac + +printf '%s' "$json" | awk ' +function rate(cur, s) { + if (match($0, "\"" cur "\":[0-9.]+")) { + s = substr($0, RSTART, RLENGTH) + sub(/^.*:/, "", s) + return s + 0 + } + return -1 +} +{ + e = rate("EUR"); p = rate("PLN"); t = rate("THB") + if (e <= 0 || p <= 0 || t <= 0) { + print "fx: incomplete rate data" + exit 1 + } + if (match($0, /"time_last_update_utc":"[^"]*"/)) { + d = substr($0, RSTART, RLENGTH) + sub(/^.*:"/, "", d) + sub(/"$/, "", d) + split(d, w, " ") + print "FX rates " w[1] " " w[2] " " w[3] " " w[4] + } + printf "1 USD = %.4g EUR\n", e + printf "1 USD = %.4g PLN\n", p + printf "1 USD = %.4g THB\n", t + printf "1 EUR = %.4g PLN\n", p / e + printf "1 EUR = %.4g THB\n", t / e + printf "1 PLN = %.4g THB\n", t / p +} +' diff --git a/cmd/todo b/cmd/todo new file mode 100755 index 0000000..7fb796b --- /dev/null +++ b/cmd/todo @@ -0,0 +1,16 @@ +#!/bin/sh +# @todo - print the shared to-do list +# +# The list lives in todo.md next to this script (cmd/todo.md). Edit that +# file directly - on the server or by scp - to change it; @todo only +# reads it, and the next call reflects the edit (no restart). + +file="$(dirname "$0")/todo.md" + +if [ ! -f "$file" ]; then + echo "todo: no list yet - create $file" +elif [ ! -s "$file" ]; then + echo "todo: the list is empty" +else + cat "$file" +fi diff --git a/cmd/uptime b/cmd/uptime new file mode 100755 index 0000000..fef74a8 --- /dev/null +++ b/cmd/uptime @@ -0,0 +1,29 @@ +#!/bin/sh +# @uptime - how long the xmppcb bot has been running +# +# The bot exec's this script directly, so its parent process ($PPID) is +# the xmppcb process itself; ps tells us how long that process has run. + +secs=$(ps -o etimes= -p "$PPID" 2>/dev/null | tr -cd '0-9') + +if [ -n "$secs" ]; then + d=$(( secs / 86400 )) + h=$(( secs % 86400 / 3600 )) + m=$(( secs % 3600 / 60 )) + s=$(( secs % 60 )) + if [ "$d" -gt 0 ]; then up="${d}d ${h}h ${m}m ${s}s" + elif [ "$h" -gt 0 ]; then up="${h}h ${m}m ${s}s" + elif [ "$m" -gt 0 ]; then up="${m}m ${s}s" + else up="${s}s" + fi + echo "xmppcb up: $up" +else + # fallback for a ps without 'etimes': raw elapsed string [dd-]hh:mm:ss + et=$(ps -o etime= -p "$PPID" 2>/dev/null | tr -d ' ') + if [ -n "$et" ]; then + echo "xmppcb up: $et" + else + echo "uptime: cannot read bot process (pid $PPID)" + exit 1 + fi +fi diff --git a/cmd/weather b/cmd/weather new file mode 100755 index 0000000..ee21233 --- /dev/null +++ b/cmd/weather @@ -0,0 +1,38 @@ +#!/bin/sh +# @weather [location] - condition, temperature and local time +# +# In chat: @weather -> default: Bangkok and Warsaw +# @weather Wroclaw -> just that location +# @weather New York -> the whole argument arrives as "$1" +# +# Weather comes from wttr.in. Its %T (time) field is served from a cache +# and goes stale, so we take only the timezone name (%Z, which is +# static) from wttr.in and compute the current local time with date. + +fmt='%l:+%c+%t+%Z' + +one() { + loc=$(printf '%s' "$1" | tr ' ' '+') + out=$(curl -fsS --max-time 8 "https://wttr.in/${loc}?format=${fmt}") || { + printf 'weather: lookup failed for %s\n' "$1" + return + } + tz=${out##* } # last token: timezone name, e.g. Asia/Bangkok + wx=${out% *} # the rest: location, condition, temperature + case "$tz" in + */*) + now=$(TZ="$tz" date '+%H:%M') + printf '%s, %s\n' "$wx" "$now" + ;; + *) + printf '%s\n' "$out" # no usable timezone: show as-is + ;; + esac +} + +if [ -n "$1" ]; then + one "$1" +else + one Bangkok + one Warsaw +fi diff --git a/config.def.h b/config.def.h index 759a941..72b9be6 100644 --- a/config.def.h +++ b/config.def.h @@ -41,3 +41,5 @@ static const char *ROOMS[] = { #define HISTORY_CONTEXT 50 /* messages used when no period given */ #define KEEPALIVE_SEC 60 /* whitespace keepalive interval */ #define TLS_VERIFY 1 /* 1 = verify server TLS certificate */ +#define CMD_DIR "cmd" /* dir of @scriptname command scripts */ +#define CMD_TIMEOUT_SEC 10 /* kill a command script after N sec */ diff --git a/xmppcb.c b/xmppcb.c index c934550..445345a 100644 --- a/xmppcb.c +++ b/xmppcb.c @@ -12,6 +12,7 @@ #endif #include +#include #include #include #include @@ -22,9 +23,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -46,6 +49,7 @@ #define REQBUF 140000 #define HTTPBUF 98304 #define LLMOUT 8192 +#define CMDOUT 4096 #define CTX_FILEBUF 131072 #define CTX_MAXLINES 4000 @@ -822,13 +826,38 @@ static time_t parse_period(const char *tok) { } static const char *help_text(void) { - return + 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)"; + " 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) { @@ -962,6 +991,138 @@ static void handle_command(Conn *c, const char *room, const char *body) { } } +/* ------------------------------------------------------------------ */ +/* @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 */ /* ------------------------------------------------------------------ */ @@ -992,6 +1153,7 @@ static void dispatch(Conn *c, const char *st) { history_append(room, nick, bdy); handle_command(c, room, bdy); + handle_script_command(c, room, bdy); } else if (strcmp(tag, "iq") == 0) { if (strstr(st, "urn:xmpp:ping")) { /* answer pings */ -- cgit v1.3