aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md52
-rwxr-xr-xcmd/fx44
-rwxr-xr-xcmd/todo16
-rwxr-xr-xcmd/uptime29
-rwxr-xr-xcmd/weather38
-rw-r--r--config.def.h2
-rw-r--r--xmppcb.c166
8 files changed, 342 insertions, 6 deletions
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 <question>` | Ask anything, using recent chat as context |
+| `@<name> [args]` | Run the script command `cmd/<name>` (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 `@<name>` 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 <ctype.h>
+#include <dirent.h>
#include <errno.h>
#include <netdb.h>
#include <poll.h>
@@ -22,9 +23,11 @@
#include <strings.h>
#include <time.h>
#include <unistd.h>
+#include <sys/select.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
+#include <sys/wait.h>
#include <openssl/err.h>
#include <openssl/ssl.h>
@@ -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 " <question> - 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) {
@@ -963,6 +992,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/<scriptname>, 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 */