Commit links for !gitlog, full changelog link for !changelog, CMake, multi-file refactor

- !gitlog now shows a link to each commit in the repository, using the
  git remote URL converted from SSH/SCP to HTTPS
- !changelog now shows a 'Full changelog: <url>' link to CHANGELOG.md
  in the repository as the last message after all entries
- Removed 'full' keyword from !gitlog and !changelog (previously showed
  full commit messages and complete changelog entries)
- Switched build system from Make to CMake with feature toggles
  (-DWITH_<FEATURE>=ON/OFF)
- Refactored single-file codebase into dedicated header files: each
  command now lives in its own cmd_*.h file, with separate modules for
  database (db.h), IRC protocol (irc.h), networking (net.h), TLS (tls.h),
  and AI (ai.h)
- Updated README.md to reflect the simplified !gitlog and !changelog
  syntax and the new CMake build instructions

(0.40.0)
This commit is contained in:
Johannes Findeisen 2026-07-23 01:46:37 +02:00
commit 739e3622b5
34 changed files with 4317 additions and 3687 deletions

2
.gitignore vendored
View file

@ -1,4 +1,6 @@
.idea/
.junie/
build/
zynk
zynk.db
*.so

View file

@ -70,18 +70,18 @@ zynk/
## IRC Commands
| Command | Description |
|---|---|
| `zynk <key>` | Look up a stored value |
| `zynk <key> = <value>` | Store a value |
| `!ping` | Responds with pong |
| `!hello` | Responds with hello and the user's name |
| `!weather [city]` | Current weather (default: Berlin) |
| `!forecast [city]` | 3-day forecast |
| `!ai <question>` | Ask the AI a question |
| `!quit` / `!die` | Shut down the bot |
| `<nick>: hi/hello/hey` | Chat greeting |
| `<nick>: <question>` | Address the bot by nick to ask AI |
| Command | Description |
|-------------------------|-----------------------------------------|
| `!zynk <key>` | Look up a stored value |
| `!zynk <key> = <value>` | Store a value |
| `!ping` | Responds with pong |
| `!hello` | Responds with hello and the user's name |
| `!weather [city]` | Current weather (default: Berlin) |
| `!forecast [city]` | 3-day forecast |
| `!ai <question>` | Ask the AI a question |
| `!quit` / `!die` | Shut down the bot |
| `<nick>: hi/hello/hey` | Chat greeting |
| `<nick>: <question>` | Address the bot by nick to ask AI |
## Conventions for Contributors

View file

@ -1,3 +1,12 @@
2026.07.23 - Commit links for !gitlog, full changelog link for !changelog, CMake, multi-file refactor. (0.40.0)
- `!gitlog` now shows a link to each commit in the repository (e.g. `184d4c4 Fix whitespace... - https://...`), using the git remote URL converted from SSH/SCP to HTTPS.
- `!changelog` now shows a "Full changelog: <url>" link to CHANGELOG.md in the repository as the last message.
- Removed `full` keyword from `!gitlog` and `!changelog` (previously showed full commit messages and complete changelog entries).
- Switched the build system from Make to CMake with feature toggles (`-DWITH_<FEATURE>=ON/OFF`).
- Refactored a single-file codebase into dedicated header files: each command now lives in its own `cmd_*.h` file, with separate modules for database (`db.h`), IRC protocol (`irc.h`), networking (`net.h`), TLS (`tls.h`), and AI (`ai.h`).
- Updated README.md to reflect the simplified `!gitlog` and `!changelog` syntax and the new CMake build instructions.
2026.07.22 - Fix whitespace collapsing in !ai, !gitlog, !changelog responses. (0.39.1)
- Fixed `collapse_spaces()` to treat tabs, newlines, and carriage returns as whitespace (not just spaces), collapsing any run of mixed whitespace into a single space.
@ -5,7 +14,6 @@
- Added `collapse_spaces()` call to `!ai` responses (after `strip_ai_phrases()`).
- Added `collapse_spaces()` call to `!changelog full` entries before sending.
- Fixed `!gitlog full` output still containing multiple spaces from git's tab-delimited commit fields.
- Version bumped to 0.39.1.
2026.07.21 - Hot reload preserves IRC session (nick, channels, voice). (0.39.0)
@ -13,13 +21,11 @@
- Removed `irc_part_all()` from the reload path — the bot no longer parts channels on reload, so voice mode (+v) and channel presence are preserved.
- Reset `pending_ai_pid`/`pending_ai_id` in the fork child to avoid stale state from orphaned AI processes.
- `!reload` and `!restart` now keep the bot's IRC session alive (no nick collision, no rejoin, no lost voice). `!rebuild`/`!code` still do a clean QUIT+exec since the binary changed.
- Version bumped to 0.39.0.
2026.07.21 - Strip opencode header from !ai responses. (0.38.3)
- Added `strip_opencode_header()`: removes the opencode header line (e.g. `> plan · big-pickle`) and any trailing blank lines from AI responses before sending to IRC.
- Pipeline order: format strip → header strip → phrase strip, so ANSI codes are cleaned up first and the `> ` prefix is reliably detected.
- Version bumped to 0.38.3.
2026.07.21 - Full mode for !gitlog/!changelog, optional pull for !rebuild. (0.38.2)
@ -30,13 +36,11 @@
- Reduced `GITLOG_MAX` from 10 to 5.
- Updated `!help` text to reflect new `[full]` and `[pull]` arguments.
- Updated README.md with `full` option for `!gitlog`/`!changelog` and `pull` option for `!rebuild`.
- Version bumped to 0.38.2.
2026.07.21 - Exempt ops from rate limiting. (0.38.1)
- Op users in channels are now exempt from rate limiting: `rate_limit_check()` calls `chan_is_op()` and allows ops through immediately.
- Removed unused `AI_REPLY_MAX` constant.
- Version bumped to 0.38.1.
2026.07.21 - Add !changelog command. (0.38.0)
@ -44,19 +48,16 @@
- Reduced `GITLOG_MAX` and `CHANGELOG_MAX` from 20 to 10.
- Updated `!help` to list `!changelog`.
- Updated README.md with `!changelog` documentation and corrected max values.
- Version bumped to 0.38.0.
2026.07.20 - Add !rebuild command. (0.37.0)
- Added `!rebuild` command (ops only, channels): runs `git pull`, `make clean`, and `make` in sequence, then triggers a hot reload if all steps succeed. If any step fails, the error output from the build is reported to the channel.
- Updated `!help` to list `!rebuild`.
- Updated README.md with `!rebuild` documentation in the Admin (Ops Only) section.
- Version bumped to 0.37.0.
2026.07.20 - Fix !seen showing UTC instead of local time. (0.36.1)
- Fixed `!seen` command storing UTC timestamps instead of local time: `seen_update()` used `datetime('now')` which returns UTC, causing `!seen <nick>` to display a time offset by the server's timezone difference (e.g. 16:00 instead of 18:00 on a UTC+2 system). Changed to `datetime('now','localtime')` so seen timestamps match the system's local time.
- Version bumped to 0.36.1.
2026.07.20 - Add !gitlog command. (0.36.0)
@ -64,7 +65,6 @@
- Updated `!help` to list `!gitlog`.
- Updated README.md with `!gitlog` documentation and `git` as a requirement.
- Added detailed doc comment to `cmd_gitlog()` matching the style of all other function documentation in zynk.c.
- Version bumped to 0.36.0.
2026.07.20 - Remove timezone abbreviation from !time response. (0.35.6)
@ -72,35 +72,29 @@
- Removed 60 lines of dead timezone abbreviation-matching code from `tz_format()` (previously mapped 45 IANA timezone names to abbreviations like CEST, EDT, JST, etc.).
- Updated README.md output format to reflect the change.
- Added `tzdata` to README.md requirements as optional (without it, `!time` falls back to 45 built-in timezones).
- Version bumped to 0.35.6.
2026.07.20 - Fix !time to work without tzdata. (0.35.5)
- Rewrote `!time` to try the system `localtime_r()` first, then fall back to a built-in timezone table with 45 IANA zones and simplified DST rules when the system can't resolve timezones (e.g. no `tzdata` installed). This means `!time Australia/Sydney` now shows correct AEST/AEDT even on minimal systems.
- Added doc comments to all undocumented functions in zynk.c: `seen_update`, `seen_lookup`, `tell_add`, `tell_deliver`, `valid_irc_word`, `normalize_city`, `collapse_spaces`, `cmd_reload`, `cmd_time`, `cmd_seen`, `cmd_tell`, `cmd_greeting_or_chat`, `tz_lookup`, `tz_format`.
- Added full list of built-in timezones to README.md (all 45 entries from `tz_table`) with region, offset, and DST status.
- Version bumped to 0.35.5.
2026.07.20 - Update README with !calc pi() docs and date requirement. (0.35.4)
- Added `pi()` constants section to README.md with examples (`!calc pi()`, `!calc pi()*2`, `!calc pi()^2`).
- Added `date` to the list of system requirements in README.md.
- Version bumped to 0.35.4.
2026.07.20 - Add pi() to !calc. (0.35.3)
- Added `pi()` function support to `!calc`: bc -l does not define `pi()` on all systems, so `cmd_calc` now prepends a `pi()` definition before evaluating expressions. `!calc pi()`, `!calc pi()*2`, `!calc pi()^2` etc. all work.
- Version bumped to 0.35.3.
2026.07.20 - Fix !time timezone conversion bug. (0.35.2)
- Fixed `!time` command: `setenv("TZ",...) + localtime()` was not reliably converting to the requested timezone. Replaced with `TZ=... date` via `popen()`, which handles all IANA timezone names correctly (e.g. `!time Australia/Sydney` now shows 11:15 AEST instead of UTC).
- Version bumped to 0.35.2.
2026.07.20 - Fix !calc not working (bc stdin not connected). (0.35.1)
- Fixed `!calc` command: the expression was never piped to `bc`'s stdin, causing empty output and a "no result" error for every expression. Added a second pipe to feed the expression into `bc` before reading the result.
- Version bumped to 0.35.1.
2026.07.20 - Calculator, time, seen, and tell commands. (0.35.0)
@ -111,40 +105,33 @@
- Added `seen` and `tell` tables to the SQLite database for persistent user tracking and message storage.
- Updated `!help` to list all new commands.
- Updated README.md with detailed documentation for all commands, including `!calc` expression syntax and `!time` timezone examples.
- Version bumped to 0.35.0.
2026.07.20 - Disabled stripping feature from 0.34.4 because it needs more testing. (0.34.5)
- Disabled `strip_opencode_header()` (introduced in 0.34.4) as it requires further testing.
- Version bumped to 0.34.5.
2026.07.20 - Strip opencode header from AI responses. (0.34.4)
- Added `strip_opencode_header()` to remove the opencode model/agent header line from AI responses before sending them to IRC.
- Version bumped to 0.34.4.
2026.07.19 - Show full AI error output on failure. (0.34.3)
- AI error messages now include the actual opencode output instead of a generic "(opencode exited with error)" message.
- Captured stderr from opencode (previously sent to /dev/null) so error details are no longer lost.
- Version bumped to 0.34.3.
2026.07.19 - Collapsed double spaces in `!forecast` and `!stock` responses (0.34.2)
- Collapsed double spaces in `!forecast` and `!stock` responses (e.g., when weather condition is empty).
- Version bumped to 0.34.2.
2026.07.19 - Fix !weather returning HTML. (0.34.1)
- Fixed `!weather` command returning `<!DOCTYPE html>` instead of weather data: wttr.in now returns HTML when it detects a browser User-Agent, so replaced the fake Firefox UA string with `zynk/VERSION`.
- Fixed `!forecast` showing all days as Sunday on musl libc (Alpine Linux): `strptime` on musl does not compute `tm_wday`, so added `mktime()` to normalize the struct before formatting.
- Version bumped to 0.34.1.
2026.07.19 - Uptime command, remove !hello. (0.34.0)
- Added `!uptime` command: displays bot uptime in a human-readable format (days/hours/minutes/seconds).
- Removed `!hello` command.
- Version bumped to 0.34.0.
2026.07.19 - Hot reload re-registration fix, !hello, docs. (0.33.1)
@ -152,7 +139,6 @@
- Added `!hello` command: responds with a greeting and the user's nick.
- Added atomic build target in Makefile: compiles to a temp file and moves into place to avoid partial binaries on failure.
- Added comprehensive doc comments to all major functions in zynk.c.
- Version bumped to 0.33.1.
2026.07.19 - Hot reload, stock quotes, and auto-recompile. (0.33.0)
@ -163,7 +149,6 @@
- Increased curl read buffer from 2 KiB to 32 KiB to handle larger API responses.
- Improved `!forecast` JSON parsing: returns a descriptive error message on parse failure instead of silently returning nothing.
- Increased AI_TIMEOUT from 240s to 600s and AI_REPLY_MAX from 400 to 2048 chars for longer AI interactions.
- Version bumped to 0.33.0.
2026.07.16 - Security hardening.

89
CMakeLists.txt Normal file
View file

@ -0,0 +1,89 @@
cmake_minimum_required(VERSION 3.13)
project(zynk VERSION 0.40.0 LANGUAGES C)
# Prefer C99; the code is compatible with C99/C11
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)
option(BUILD_STRICT "Enable strict compiler warnings" ON)
# Feature toggles — all ON by default, disable with -DWITH_<FEATURE>=OFF
option(WITH_ZYNK "Key-value store command (!zynk)" ON)
option(WITH_PING "Ping/pong command" ON)
option(WITH_VERSION "Version command" ON)
option(WITH_HELP "Help command" ON)
option(WITH_QUIT "Quit/die shutdown command" ON)
option(WITH_WEATHER "Weather command (requires curl)" ON)
option(WITH_FORECAST "Forecast command (requires curl)" ON)
option(WITH_STOCK "Stock quote command (requires curl)" ON)
option(WITH_CALC "Calculator command (requires bc)" ON)
option(WITH_TIME "Time/timezone command" ON)
option(WITH_SEEN "Seen command (tracks nick activity)" ON)
option(WITH_TELL "Tell command (pending messages)" ON)
option(WITH_GITLOG "Git log command" ON)
option(WITH_CHANGELOG "Changelog command" ON)
option(WITH_AI "AI question command (requires opencode)" ON)
option(WITH_CODE "AI code change command (ops only)" OFF)
option(WITH_REBUILD "Rebuild command (ops only)" OFF)
option(WITH_UPTIME "Uptime command" ON)
option(WITH_RELOAD "Hot-reload command (ops only)" ON)
option(WITH_RESTART "Restart command (ops only)" ON)
option(WITH_GREETING_OR_CHAT "Greeting/chat with AI fallback" ON)
# Map WITH_* options to HAS_CMD_* compile definitions
foreach(_feat ZYNK PING VERSION HELP QUIT WEATHER FORECAST STOCK
CALC TIME SEEN TELL GITLOG CHANGELOG AI CODE
REBUILD UPTIME RELOAD RESTART GREETING_OR_CHAT)
string(TOLOWER "${_feat}" _feat_lower)
if(WITH_${_feat})
list(APPEND _cmd_defs "HAS_CMD_${_feat}")
endif()
endforeach()
# Print feature status
message(STATUS "")
message(STATUS "zynk feature configuration:")
message(STATUS "=============================================")
foreach(_feat ZYNK PING VERSION HELP QUIT WEATHER FORECAST STOCK
CALC TIME SEEN TELL GITLOG CHANGELOG AI CODE
REBUILD UPTIME RELOAD RESTART GREETING_OR_CHAT)
string(TOLOWER "${_feat}" _feat_lower)
if(WITH_${_feat})
message(STATUS "[ON] ${_feat} (!${_feat_lower})")
else()
message(STATUS "[OFF] ${_feat} (!${_feat_lower})")
endif()
endforeach()
message(STATUS "=============================================")
message(STATUS "")
# Homebrew OpenSSL hint on macOS (set before find_package)
if(APPLE AND DEFINED ENV{HOMEBREW_PREFIX})
list(APPEND CMAKE_PREFIX_PATH
"$ENV{HOMEBREW_PREFIX}/opt/openssl"
"$ENV{HOMEBREW_PREFIX}/opt/openssl@3")
endif()
find_package(OpenSSL REQUIRED)
add_executable(zynk zynk.c)
if(BUILD_STRICT)
if(CMAKE_C_COMPILER_ID MATCHES "Clang|GNU")
target_compile_options(zynk PRIVATE -Wall -Wextra -pedantic)
endif()
endif()
target_compile_definitions(zynk PRIVATE ${_cmd_defs} ZYNK_VERSION="${PROJECT_VERSION}")
target_link_libraries(zynk PRIVATE OpenSSL::SSL OpenSSL::Crypto)
# dlopen/dlsym come from libdl on Unix-like systems (not needed on macOS)
if(UNIX AND NOT APPLE)
find_library(DL_LIBRARY dl)
if(DL_LIBRARY)
target_link_libraries(zynk PRIVATE ${DL_LIBRARY})
endif()
endif()
install(TARGETS zynk RUNTIME DESTINATION bin)

View file

@ -1,34 +0,0 @@
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
CC ?= gcc
CFLAGS ?= -Wall -Wextra -pedantic
LDLIBS ?= -lssl -lcrypto -ldl
# Homebrew OpenSSL on macOS
BREW_SSL := $(shell brew --prefix openssl 2>/dev/null)
ifneq ($(BREW_SSL),)
CFLAGS += -I$(BREW_SSL)/include
LDFLAGS += -L$(BREW_SSL)/lib
endif
.PHONY: all clean install uninstall
all: zynk
zynk: zynk.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@.tmp $< $(LDLIBS)
mv -f $@.tmp $@
calc: zynk.c
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $< $(LDLIBS)
clean:
rm -vf zynk
install: calc
mkdir -p $(DESTDIR)$(BINDIR)
cp calc $(DESTDIR)$(BINDIR)/zynk
uninstall:
rm -vf $(DESTDIR)$(BINDIR)/zynk

View file

@ -4,7 +4,7 @@ A single-file IRC bot in C. Features a key-value store backed by SQLite3, weathe
## Features
- **Key-Value Store**: Persist facts, notes, or any data with `zynk key = value`
- **Key-Value Store**: Persist facts, notes, or any data with `!zynk key = value`; Overwrite existing keys with `!zynk! key = value`
- **Weather**: Current conditions and 3-day forecasts via `wttr.in`
- **Stock Quotes**: Real-time stock prices via Yahoo Finance
- **Calculator**: Evaluate math expressions with `!calc`
@ -35,14 +35,73 @@ A single-file IRC bot in C. Features a key-value store backed by SQLite3, weathe
## Build
### Quick build
```sh
make
cmake -B build && cmake --build build
```
Set custom compiler or flags:
The binary is placed in `build/zynk`.
### Install
```sh
make CC=clang CFLAGS="-O2 -Wall -Wextra"
cmake --build build --target install
```
### Feature toggles
All commands are enabled by default. Disable any command at build time with `-DWITH_<FEATURE>=OFF`:
```sh
cmake -B build -DWITH_WEATHER=OFF -DWITH_AI=OFF
cmake --build build
```
Available options (the `!command` shown in parentheses):
| Option | Command | Description | Default |
|-------------------------|------------------|----------------------------------|---------|
| `WITH_ZYNK` | `!zynk` | Key-value store | ON |
| `WITH_PING` | `!ping` | Ping/pong | ON |
| `WITH_VERSION` | `!version` | Version info | ON |
| `WITH_HELP` | `!help` | Help listing | ON |
| `WITH_QUIT` | `!quit` / `!die` | Shut down the bot | ON |
| `WITH_WEATHER` | `!weather` | Weather lookup (requires curl) | ON |
| `WITH_FORECAST` | `!forecast` | 3-day forecast (requires curl) | ON |
| `WITH_STOCK` | `!stock` | Stock quotes (requires curl) | ON |
| `WITH_CALC` | `!calc` | Calculator (requires bc) | ON |
| `WITH_TIME` | `!time` | Time/timezone lookup | ON |
| `WITH_SEEN` | `!seen` | User tracking | ON |
| `WITH_TELL` | `!tell` | Offline messages | ON |
| `WITH_GITLOG` | `!gitlog` | Recent git commits | ON |
| `WITH_CHANGELOG` | `!changelog` | Changelog entries | ON |
| `WITH_AI` | `!ai` | AI questions (requires opencode) | ON |
| `WITH_CODE` | `!code` | AI code changes (ops only) | OFF |
| `WITH_REBUILD` | `!rebuild` | Rebuild & restart (ops only) | OFF |
| `WITH_UPTIME` | `!uptime` | Uptime display | ON |
| `WITH_RELOAD` | `!reload` | Hot reload (ops only) | ON |
| `WITH_RESTART` | `!restart` | Restart (ops only) | ON |
| `WITH_GREETING_OR_CHAT` | `<nick>: <text>` | Greeting/chat with AI fallback | ON |
### Minimal build
Disable everything except the core store, quit, and help:
```sh
cmake -B build \
-DWITH_PING=OFF -DWITH_VERSION=OFF -DWITH_WEATHER=OFF -DWITH_FORECAST=OFF \
-DWITH_STOCK=OFF -DWITH_CALC=OFF -DWITH_TIME=OFF -DWITH_SEEN=OFF \
-DWITH_TELL=OFF -DWITH_GITLOG=OFF -DWITH_CHANGELOG=OFF -DWITH_AI=OFF \
-DWITH_CODE=OFF -DWITH_REBUILD=OFF -DWITH_UPTIME=OFF -DWITH_RELOAD=OFF \
-DWITH_RESTART=OFF -DWITH_GREETING_OR_CHAT=OFF
cmake --build build
```
### Clean
```sh
rm -rf build
```
## Usage
@ -85,12 +144,12 @@ make CC=clang CFLAGS="-O2 -Wall -Wextra"
### Key-Value Store
| Command | Description |
|-------------------------|-----------------------------------------------|
| `zynk <key>` | Look up a stored value |
| `zynk <key> = <value>` | Store a value (key is lowercased) |
| `zynk! <key> = <value>` | Overwrite an existing value (ops in channels) |
| `zynk` | Get a random stored entry |
| Command | Description |
|------------------------------|-----------------------------------------------|
| `!zynk <key>` | Look up a stored value |
| `!zynk <key> = <value>` | Store a value (key is lowercased) |
| `!zynk! <key> = <value>` | Overwrite an existing value (ops in channels) |
| `!zynk` | Get a random stored entry |
### Weather & Finance
@ -108,8 +167,8 @@ make CC=clang CFLAGS="-O2 -Wall -Wextra"
| `!time [timezone]` | Show current time (see below) |
| `!seen <nick>` | Check when a user was last seen |
| `!tell <nick> <message>` | Leave a message for an offline user |
| `!gitlog [count] [full]` | Show recent git commits (default: 1, max: 5; `full` shows full commit messages) |
| `!changelog [count] [full]` | Show changelog entries (default: 1, max: 10; `full` shows full entry text) |
| `!gitlog [count]` | Show recent git commits with links (default: 5, max: 20) |
| `!changelog [count]` | Show changelog entries (default: 1, max: 10) |
#### Time (`!time`)

346
ai.h Normal file
View file

@ -0,0 +1,346 @@
#ifndef AI_H
#define AI_H
#include "zynk.h"
/*
* strip_ai_phrases - Remove common AI disclaimer phrases from text.
*
* Scans the input string and removes occurrences of phrases like "as an AI",
* "I'm a large language model", "I don't have personal opinions", etc.
* Matching is case-insensitive. Also strips trailing punctuation and spaces
* left after phrase removal. Used to clean up opencode output before sending
* it to IRC, making responses more natural.
*
* Parameters:
* s - The string to clean in-place. Must be writable.
*
* Returns: void. The string is modified in-place.
*/
void strip_ai_phrases(char *s) {
static const char *phrases[] = {
"as an ai", "as an ai language model", "as an ai assistant",
"i'm an ai", "i am an ai", "i'm just an ai", "i am just an ai",
"as a large language model", "as an llm",
"i'm an ai assistant", "i am an ai assistant",
"i don't have personal", "i do not have personal",
"i don't have emotions", "i do not have emotions",
"i'm a large language model", "i am a large language model",
"i'm an llm", "i am an llm",
};
char buf[6144];
size_t len = strlen(s);
if (len >= sizeof buf) return;
memcpy(buf, s, len + 1);
char *dst = s;
for (char *src = buf; *src; ) {
int skipped = 0;
for (size_t i = 0; i < sizeof phrases / sizeof phrases[0]; i++) {
size_t plen = strlen(phrases[i]);
if (strncasecmp(src, phrases[i], plen) == 0) {
src += plen;
while (*src == ' ' || *src == ',' || *src == '.' || *src == '!' || *src == '?') src++;
skipped = 1;
break;
}
}
if (!skipped) *dst++ = *src++;
}
*dst = 0;
}
/*
* strip_opencode_header - Strip the opencode header line from AI output.
*
* Removes the leading header line produced by opencode (e.g.
* "> plan · big-pickle " or "> build · big-pickle ") and any blank
* lines that follow it. Must be called AFTER strip_formatting() has
* removed ANSI escape codes so the '>' prefix is at position 0.
* If the string does not start with '>', it is left unchanged.
*
* Parameters:
* s - The string to modify in-place. Must be writable.
*
* Returns: void. The string is modified in-place.
*/
void strip_opencode_header(char *s) {
while (*s == '\n') {
memmove(s, s + 1, strlen(s));
}
if (s[0] != '>') return;
char *nl = strchr(s, '\n');
if (!nl) { s[0] = 0; return; }
char *p = nl + 1;
while (*p == '\n') p++;
size_t len = strlen(p);
memmove(s, p, len + 1);
}
/* ---- AI pipeline ---- */
volatile pid_t pending_ai_pid;
volatile long long pending_ai_id;
const char *g_opencode_bin = OPENCODE_BIN;
/*
* ai_child_task - Child process entry point for running an AI query.
*
* Runs in a forked child process. Sets an alarm timeout (AI_TIMEOUT seconds),
* closes the parent's socket fd (but doesn't TLS-shutdown to avoid breaking
* the parent's connection), opens its own DB handle, retrieves the question
* and recent answer history, builds a prompt prefix with system instructions
* (different for "build" agent vs. "plan" agent), then fork+exec's opencode
* with the combined prompt. Reads up to 6KB of output, strips formatting
* and AI phrases, stores the result in the database, and exits. The parent
* polls for completion and delivers the answer to IRC.
*
* Parameters:
* s - The IRC session (only fd is used; ssl is intentionally not touched).
* id - The ai_pending row ID for this query.
* agent - The opencode agent name ("build" for code changes, NULL for plan mode).
*
* Returns: Does not return (calls exit(0) or exit(1)).
*/
void ai_child_task(Session *s, long long id, const char *agent) {
signal(SIGALRM, SIG_DFL);
alarm(AI_TIMEOUT);
/* Don't call tls_cleanup() — SSL_shutdown would send a close_notify on
* the shared fd, breaking the parent's connection. Just release our
* fd reference; the kernel will close it on exit. */
if (s->fd >= 0) close(s->fd);
s->fd = -1;
s->ssl = NULL;
db_close(DB);
db_handle = NULL;
if (db_open("zynk.db", &db_handle) != SQLITE_OK) exit(1);
char *q = ai_get_question(id);
if (!q) { db_close(DB); exit(1); }
char *history[MAX_HISTORY];
char hkey[MAX_HISTORY][12];
for (int i = 0; i < MAX_HISTORY; i++) {
snprintf(hkey[i], sizeof hkey[i], "lasta%02d", i + 1);
history[i] = db_get_raw(hkey[i]);
}
int have_history = 0;
for (int i = 0; i < MAX_HISTORY; i++) { if (history[i]) { have_history = 1; break; } }
char prefix[2048];
int plen;
if (agent) {
plen = snprintf(prefix, sizeof prefix,
"Your name is zynk. You are a coding assistant in a project. Execute code changes as requested. "
"Make real file edits and run commands as needed. Answer in English when asked in English, answer in German when asked in German. "
"Max 2000 chars. No disclaimers or AI-sounding phrases. No markdown/formatting. "
"Just make the changes directly and confirm what you did.");
} else {
plen = snprintf(prefix, sizeof prefix,
"Your name is zynk. You are in plan mode - you can only answer questions and discuss code, "
"but you CANNOT make any changes to files or execute commands. If someone asks you to change or edit code, "
"tell them you are in plan mode and can only answer questions. Suggest they use !code <instruction> to make actual code changes. "
"Answer briefly. Answer in English when asked in English, answer in German when asked in German. Max 800 chars. "
"No disclaimers or AI-sounding phrases. No markdown/formatting. "
"Just answer directly.");
}
if (have_history) {
int space = (int)sizeof prefix - plen;
if (space > 0)
plen += snprintf(prefix + plen, space,
" My recent answers for context (only use if absolutely necessary): ");
if (plen >= (int)sizeof prefix) plen = (int)sizeof prefix - 1;
for (int i = 0; i < MAX_HISTORY; i++) {
space = (int)sizeof prefix - plen;
if (space <= 0) break;
if (history[i]) {
plen += snprintf(prefix + plen, space, "[%s] ", history[i]);
if (plen >= (int)sizeof prefix) plen = (int)sizeof prefix - 1;
}
}
}
for (int i = 0; i < MAX_HISTORY; i++) free(history[i]);
char combined[8192];
snprintf(combined, sizeof combined, "%s%s", prefix, q ? q : "");
free(q);
int ai_pipe[2];
if (pipe(ai_pipe) < 0) { ai_set_answer(id, "AI query failed (pipe error)"); db_close(DB); exit(0); }
pid_t cmd_pid = fork();
if (cmd_pid < 0) { close(ai_pipe[0]); close(ai_pipe[1]); ai_set_answer(id, "AI query failed (fork error)"); db_close(DB); exit(0); }
if (cmd_pid == 0) {
close(ai_pipe[0]);
dup2(ai_pipe[1], STDOUT_FILENO);
dup2(ai_pipe[1], STDERR_FILENO);
close(ai_pipe[1]);
if (agent)
execl(g_opencode_bin, "opencode", "run", "--dangerously-skip-permissions", "--agent", agent, combined, (char *)NULL);
else
execl(g_opencode_bin, "opencode", "run", "--dangerously-skip-permissions", "--agent", "plan", combined, (char *)NULL);
_exit(127);
}
close(ai_pipe[1]);
char out[6144], tmp[1024];
size_t olen = 0;
ssize_t n;
while ((n = read(ai_pipe[0], tmp, sizeof tmp)) > 0) {
size_t avail = sizeof out - 1 - olen;
if (avail > 0) {
size_t copy = (size_t)n < avail ? (size_t)n : avail;
memcpy(out + olen, tmp, copy);
olen += copy;
}
}
close(ai_pipe[0]);
int status;
waitpid(cmd_pid, &status, 0);
while (olen > 0 && (out[olen-1] == '\n' || out[olen-1] == '\r')) olen--;
out[olen] = 0;
int rc = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
if (rc != 0) {
char errmsg[4200];
strip_formatting(out);
if (olen > 0)
snprintf(errmsg, sizeof errmsg, "AI error (exit %d): %.4000s", rc, out);
else
snprintf(errmsg, sizeof errmsg, "AI error (exit %d, no output)", rc);
ai_set_answer(id, errmsg);
} else if (olen == 0) {
ai_set_answer(id, "AI returned an empty response");
} else {
strip_formatting(out);
strip_opencode_header(out);
strip_ai_phrases(out);
collapse_spaces(out);
ai_set_answer(id, out);
db_update_lasta(out);
}
db_close(DB);
exit(0);
}
/*
* ai_ask - Queue a new AI query and spawn a child process to handle it.
*
* Rate-limits the requesting nick, checks that no other AI query is
* already in progress (only one concurrent query allowed), records the
* question in the history ring buffer, creates a database record via
* ai_start(), then fork()s a child to run ai_child_task(). Stores the
* child PID and query ID in global variables for polling in the main loop.
* Logs the query to stderr.
*
* Parameters:
* s - The IRC session.
* question - The user's question or instruction.
* reply_target - Channel or nick where the answer should be sent.
* src_nick - The IRC nick of the user who asked.
* agent - The opencode agent ("build" for code, NULL for plan), or
* "plan" passed as string for plan-mode queries.
*
* Returns: void. Sends error messages to IRC on failure.
*/
void ai_ask(Session *s, const char *question, const char *reply_target, const char *src_nick, const char *agent) {
if (rate_limit_check(src_nick, reply_target) < 0) return;
if (pending_ai_pid > 0) {
irc_msg(s, reply_target, "Already processing an AI query, please wait.");
return;
}
db_update_lastq(question);
long long id = 0;
if (ai_start(question, reply_target, src_nick, &id) != 0) {
irc_msg(s, reply_target, "Error starting AI query");
return;
}
pid_t pid = fork();
if (pid == -1) { ai_reset(id); irc_msg(s, reply_target, "Error forking"); return; }
if (pid == 0) { ai_child_task(s, id, agent); }
pending_ai_pid = pid;
pending_ai_id = id;
log_stamp(); fprintf(stderr, CLR_BLUE "AI started (PID %d, ID %lld) from %s:" CLR_RESET " %s\n", pid, id, src_nick, question);
}
/*
* ai_check_completion - Poll for completed AI child processes.
*
* Called from the main poll loop on every iteration. Uses waitpid(WNOHANG)
* to check if the pending AI child has exited without blocking. If done,
* retrieves the answer from the database, splits on the \x1f separator to
* extract the delivery target, and sends the answer to IRC in chunks of
* up to AI_CHUNK_MAX characters (word-wrapped). Logs the result. If the
* query was a code change request (!code), triggers try_compile_and_restart()
* after delivering the answer. Handles timeouts (SIGALRM) and other failures
* with appropriate error messages.
*
* Parameters:
* s - The IRC session.
*
* Returns: void.
*/
void ai_check_completion(Session *s) {
if (pending_ai_pid <= 0) return;
int wstatus;
pid_t done_pid = pending_ai_pid;
long long done_id = pending_ai_id;
if (waitpid(pending_ai_pid, &wstatus, WNOHANG) <= 0) return;
pending_ai_pid = 0;
char *nick = ai_get_nick(done_id);
char *result = ai_get_answer(done_id);
int is_code_change = 0;
char *question = ai_get_question(done_id);
if (question && strncmp(question, "[CODE CHANGE REQUEST", 20) == 0) is_code_change = 1;
free(question);
if (result) {
char *answer = result;
char *sep = strchr(result, '\037');
char *target = NULL;
if (sep) { *sep = 0; target = sep + 1; }
if (target) {
size_t alen = strlen(answer);
#define AI_CHUNK_MAX 450
size_t pos = 0;
int chunks = 0;
while (pos < alen) {
size_t end = pos + AI_CHUNK_MAX;
if (end >= alen) {
irc_msg(s, target, answer + pos);
chunks++;
break;
}
while (end > pos && answer[end] != ' ') end--;
if (end <= pos) end = pos + AI_CHUNK_MAX;
char save = answer[end];
answer[end] = 0;
irc_msg(s, target, answer + pos);
answer[end] = save;
pos = end + 1;
chunks++;
}
log_stamp(); fprintf(stderr, CLR_BLUE "AI done PID %d from %s: %zu chars sent as %d chunks to %s" CLR_RESET "\n", done_pid, nick ? nick : "?", alen, chunks, target);
if (is_code_change) try_compile_and_restart(s, target);
}
free(result);
ai_reset(done_id);
} else {
char *target = NULL;
void *stmt;
if (db_prepare(DB, "SELECT target FROM ai_pending WHERE id=?", -1, &stmt, NULL) == SQLITE_OK) {
db_bind_int64(stmt, 1, done_id);
if (db_step(stmt) == SQLITE_ROW) {
const char *t = (const char*)db_column_text(stmt, 0);
if (t) target = strdup(t);
}
db_finalize(stmt);
}
if (WIFSIGNALED(wstatus) && WTERMSIG(wstatus) == SIGALRM) {
if (target) {
char msg[96];
snprintf(msg, sizeof msg, "AI query timed out after %d seconds.", AI_TIMEOUT);
irc_msg(s, target, msg);
log_stamp(); fprintf(stderr, CLR_RED "AI done PID %d from %s: timed out for %s" CLR_RESET "\n", done_pid, nick ? nick : "?", target);
}
} else if (target) {
irc_msg(s, target, "AI query failed.");
log_stamp(); fprintf(stderr, CLR_RED "AI done PID %d from %s: failed for %s" CLR_RESET "\n", done_pid, nick ? nick : "?", target);
}
free(target);
ai_reset(done_id);
}
free(nick);
}
#endif

39
cmd_ai.h Normal file
View file

@ -0,0 +1,39 @@
#ifndef CMD_AI_H
#define CMD_AI_H
#include "zynk.h"
/*
* cmd_ai - Handle the "!ai <question>" command.
*
* Passes the question to ai_ask() in plan mode (agent=NULL), which means
* the AI can only discuss and answer questions but cannot modify files.
* Extracts the question text after "!ai ", trims trailing whitespace.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_ai(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!ai", 3) != 0) return 0;
if (msg[3] != ' ' && msg[3] != '\0') return 0;
const char *rest = msg[3] == ' ' ? msg + 4 : "";
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !ai <question>");
return 1;
}
char question[2048];
snprintf(question, sizeof question, "%s", rest);
char *end = question + strlen(question);
while (end > question && end[-1] == ' ') end--;
*end = 0;
ai_ask(s, question, reply_target, src_nick, NULL);
return 1;
}
#endif

82
cmd_calc.h Normal file
View file

@ -0,0 +1,82 @@
#ifndef CMD_CALC_H
#define CMD_CALC_H
#include "zynk.h"
/*
* cmd_calc - Handle the "!calc <expression>" command.
*
* Evaluates a mathematical expression using bc(1) with the -l math
* library (providing pi, sin, cos, etc.). Forks a child process,
* pipes the expression to bc's stdin, and reads the result from stdout.
* Defines pi() as a built-in. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_calc(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!calc", 5) != 0) return 0;
if (msg[5] != ' ' && msg[5] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
const char *rest = msg[5] == ' ' ? msg + 6 : "";
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !calc <expression> (e.g. !calc 2+2, !calc (3*7)+1)");
return 1;
}
int in_pipe[2], out_pipe[2];
if (pipe(in_pipe) < 0 || pipe(out_pipe) < 0) { irc_msg(s, reply_target, "Calc: internal error"); return 1; }
pid_t pid = fork();
if (pid < 0) { close(in_pipe[0]); close(in_pipe[1]); close(out_pipe[0]); close(out_pipe[1]); irc_msg(s, reply_target, "Calc: fork failed"); return 1; }
if (pid == 0) {
close(in_pipe[1]);
close(out_pipe[0]);
dup2(in_pipe[0], STDIN_FILENO);
close(in_pipe[0]);
dup2(out_pipe[1], STDOUT_FILENO);
close(out_pipe[1]);
execlp("bc", "bc", "-l", (char *)NULL);
_exit(127);
}
close(in_pipe[0]);
close(out_pipe[1]);
const char *pidef = "define pi() { return 3.14159265358979323844; }\n";
write(in_pipe[1], pidef, strlen(pidef));
write(in_pipe[1], rest, strlen(rest));
write(in_pipe[1], "\n", 1);
close(in_pipe[1]);
char buf[512], tmp[256];
size_t len = 0;
ssize_t n;
while ((n = read(out_pipe[0], tmp, sizeof tmp - 1)) > 0) {
size_t avail = sizeof buf - 1 - len;
if (avail > 0) {
size_t copy = (size_t)n < avail ? (size_t)n : avail;
memcpy(buf + len, tmp, copy);
len += copy;
}
}
close(out_pipe[0]);
int status;
waitpid(pid, &status, 0);
while (len > 0 && (buf[len-1] == '\n' || buf[len-1] == '\r')) len--;
buf[len] = 0;
char *nl = strchr(buf, '\n');
if (nl) *nl = 0;
if (len == 0) {
irc_msg(s, reply_target, "Calc: no result (check expression syntax)");
} else {
char resp[640];
snprintf(resp, sizeof resp, "%s: %s", src_nick, buf);
irc_msg(s, reply_target, resp);
}
log_stamp(); fprintf(stderr, CLR_YELLOW "CALC %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, rest);
return 1;
}
#endif

107
cmd_changelog.h Normal file
View file

@ -0,0 +1,107 @@
#ifndef CMD_CHANGELOG_H
#define CMD_CHANGELOG_H
#include "zynk.h"
/*
* cmd_changelog - Handle the "!changelog" command.
*
* Reads CHANGELOG.md from the project root and displays the last N date/title
* lines (default: CHANGELOG_COUNT). Lines starting with a date (YYYY.MM.DD)
* are treated as entries. Accepts an optional count argument (CHANGELOG_MAX).
* Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_changelog(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!changelog", 10) != 0) return 0;
if (msg[10] != ' ' && msg[10] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
int count = CHANGELOG_COUNT;
const char *rest = msg[10] == ' ' ? msg + 11 : "";
while (*rest == ' ') rest++;
if (*rest) {
if (isdigit((unsigned char)*rest)) {
count = atoi(rest);
if (count <= 0) count = CHANGELOG_COUNT;
if (count > CHANGELOG_MAX) count = CHANGELOG_MAX;
while (*rest && isdigit((unsigned char)*rest)) rest++;
while (*rest == ' ') rest++;
}
}
char remote_url[512] = {0};
FILE *fp = popen("git remote get-url origin 2>/dev/null", "r");
if (fp) {
if (fgets(remote_url, sizeof remote_url, fp)) {
size_t rlen = strlen(remote_url);
while (rlen > 0 && (remote_url[rlen-1] == '\n' || remote_url[rlen-1] == '\r')) rlen--;
remote_url[rlen] = 0;
if (strncmp(remote_url, "ssh://git@", 10) == 0) {
char *host = remote_url + 10;
char *slash = strchr(host, '/');
if (slash) {
char tmp[512];
snprintf(tmp, sizeof tmp, "https://%.*s%s", (int)(slash - host), host, slash);
strncpy(remote_url, tmp, sizeof remote_url - 1);
remote_url[sizeof remote_url - 1] = 0;
}
} else if (strncmp(remote_url, "git@", 4) == 0) {
char *host = remote_url + 4;
char *path = strchr(host, ':');
if (path) {
char tmp[512];
snprintf(tmp, sizeof tmp, "https://%s%s", host, path);
strncpy(remote_url, tmp, sizeof remote_url - 1);
remote_url[sizeof remote_url - 1] = 0;
}
}
size_t ulen = strlen(remote_url);
if (ulen > 0 && remote_url[ulen-1] == '/') remote_url[--ulen] = 0;
if (ulen > 4 && strcmp(remote_url + ulen - 4, ".git") == 0) { ulen -= 4; remote_url[ulen] = 0; }
}
pclose(fp);
}
FILE *f = fopen("CHANGELOG.md", "r");
if (!f) { irc_msg(s, reply_target, "changelog: CHANGELOG.md not found"); return 1; }
char *line = NULL;
size_t cap = 0;
const char *headers[256];
int total = 0;
while (getline(&line, &cap, f) != -1) {
size_t len = strlen(line);
while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) line[--len] = 0;
if (len > 10 && isdigit((unsigned char)line[0]) && isdigit((unsigned char)line[1]) &&
isdigit((unsigned char)line[2]) && isdigit((unsigned char)line[3]) &&
line[4] == '.' && isdigit((unsigned char)line[5]) && isdigit((unsigned char)line[6]) &&
line[7] == '.' && isdigit((unsigned char)line[8]) && isdigit((unsigned char)line[9]) &&
line[10] == ' ') {
if (total < 256) headers[total++] = strdup(line);
}
}
free(line);
fclose(f);
int showed = count < total ? count : total;
struct timespec ts_msg = { .tv_sec = 0, .tv_nsec = 200000000 };
for (int i = 0; i < showed; i++) {
irc_msg(s, reply_target, headers[i]);
free((void *)headers[i]);
if (i + 1 < showed) nanosleep(&ts_msg, NULL);
}
for (int i = showed; i < total; i++) free((void *)headers[i]);
if (total == 0) { irc_msg(s, reply_target, "changelog: no entries found"); return 1; }
if (*remote_url) {
char link[1024];
snprintf(link, sizeof link, "Full changelog: %s/blob/master/CHANGELOG.md", remote_url);
irc_msg(s, reply_target, link);
}
log_stamp(); fprintf(stderr, CLR_YELLOW "CHANGELOG %s from %s (count=%d, showed=%d)" CLR_RESET "\n", reply_target, src_nick, count, showed);
return 1;
}
#endif

53
cmd_code.h Normal file
View file

@ -0,0 +1,53 @@
#ifndef CMD_CODE_H
#define CMD_CODE_H
#include "zynk.h"
/*
* cmd_code - Handle the "!code <instruction>" command for AI code changes.
*
* Requires channel op and must be used in a channel (not PM). Wraps the
* instruction with context ("[CODE CHANGE REQUEST from op nick in #chan]")
* and passes it to ai_ask() with agent="build", which gives the AI full
* file-editing and command-execution capabilities. After the AI responds,
* try_compile_and_restart() is triggered to auto-deploy if compilation
* succeeds. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
* tgt - The message target (channel or nick).
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_code(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
if (strncmp(msg, "!code", 5) != 0) return 0;
if (msg[5] != ' ' && msg[5] != '\0') return 0;
if (tgt[0] != '#') {
irc_msg(s, reply_target, "!code can only be used in a channel");
return 1;
}
if (!chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need to be a channel op to use !code");
return 1;
}
const char *rest = msg[5] == ' ' ? msg + 6 : "";
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !code <instruction for code change>");
return 1;
}
char question[2048];
snprintf(question, sizeof question,
"[CODE CHANGE REQUEST from op %s in %s] %s", src_nick, tgt, rest);
char *end = question + strlen(question);
while (end > question && end[-1] == ' ') end--;
*end = 0;
ai_ask(s, question, reply_target, src_nick, "build");
log_stamp(); fprintf(stderr, CLR_RED "CODE %s from %s (op in %s):" CLR_RESET " %s\n", reply_target, src_nick, tgt, rest);
return 1;
}
#endif

114
cmd_forecast.h Normal file
View file

@ -0,0 +1,114 @@
#ifndef CMD_FORECAST_H
#define CMD_FORECAST_H
#include "zynk.h"
/*
* fetch_forecast - Fetch and parse a 3-day weather forecast from wttr.in.
*
* Fetches the JSON forecast (format=j1) from wttr.in for the given city,
* then manually parses the "weather" array to extract date, average
* temperature, and weather condition for up to 3 days. Dates are formatted
* as "Mon DD Mon" (e.g. "Mon 19 Jul"). Returns a single-line summary
* string like "Forecast for Berlin: Mon 19 Jul: 22°C Partly cloudy / ...".
*
* Parameters:
* city_url - URL-encoded city name for the API request.
* city_display - Human-readable city name for the response.
*
* Returns: A newly allocated forecast string, or NULL/ error message on
* failure. Caller must free() the result.
*/
char *fetch_forecast(const char *city_url, const char *city_display) {
char url[512];
snprintf(url, sizeof url, "https://wttr.in/%s?format=j1", city_url);
char *json = fetch_url(url);
if (!json) return NULL;
char *p = strstr(json, "\"weather\"");
if (!p) {
char *err = NULL;
size_t jlen = strlen(json);
if (jlen > 400) jlen = 400;
asprintf(&err, "Forecast parse error (response: %.*s)", (int)jlen, json);
free(json);
return err;
}
p = strchr(p, '[');
if (!p) { free(json); return NULL; }
p++;
char line[3][256], fmt_date[3][32];
int ndays = 0;
for (int i = 0; i < 3; i++) {
p = strchr(p, '{');
if (!p) break;
const char *obj_end = p + 1;
int depth = 1;
while (*obj_end && depth > 0) { if (*obj_end == '{') depth++; else if (*obj_end == '}') depth--; obj_end++; }
char date[16]="", temp[8]="", cond[64]="";
char *db = strstr(p, "\"date\": \"");
if (db && db < obj_end) { db += 9; char *dq = strchr(db, '"'); if (dq && dq < obj_end) { size_t l = dq - db; if (l>15) l=15; memcpy(date,db,l); date[l]=0; } }
char *tb = strstr(p, "\"avgtempC\": \"");
if (tb && tb < obj_end) { tb += 13; char *tq = strchr(tb, '"'); if (tq && tq < obj_end) { size_t l = tq - tb; if (l>7) l=7; memcpy(temp,tb,l); temp[l]=0; } }
char *hb = strstr(p, "\"hourly\"");
if (hb && hb < obj_end) {
hb = strchr(hb, '[');
if (hb && hb < obj_end) {
hb++;
char *vb = strstr(hb, "\"value\": \"");
if (vb && vb < obj_end) { vb += 9; char *vq = strchr(vb, '"'); if (vq && vq < obj_end) { size_t l = vq - vb; if (l>63) l=63; memcpy(cond,vb,l); cond[l]=0; } }
}
}
struct tm tm = {0};
if (strptime(date, "%Y-%m-%d", &tm)) { mktime(&tm); strftime(fmt_date[ndays], sizeof fmt_date[ndays], "%a %d %b", &tm); }
else snprintf(fmt_date[ndays], sizeof fmt_date[ndays], "%s", date);
snprintf(line[ndays], sizeof line[ndays], "%s: %s\u00b0C %s", fmt_date[ndays], temp, cond);
ndays++;
p = (char*)obj_end;
}
free(json);
if (!ndays) return NULL;
char *out = NULL;
if (ndays == 1) { if (asprintf(&out, "Forecast for %s: %s", city_display, line[0]) < 0) out = NULL; }
else if (ndays == 2) { if (asprintf(&out, "Forecast for %s: %s / %s", city_display, line[0], line[1]) < 0) out = NULL; }
else { if (asprintf(&out, "Forecast for %s: %s / %s / %s", city_display, line[0], line[1], line[2]) < 0) out = NULL; }
if (out) collapse_spaces(out);
return out;
}
/*
* cmd_forecast - Handle the "!forecast [city]" command.
*
* Fetches a 3-day weather forecast from wttr.in using the JSON API.
* Delegates to fetch_forecast() for parsing. Defaults to Berlin if
* no city is specified. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_forecast(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!forecast", 9) != 0) return 0;
if (msg[9] != ' ' && msg[9] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char city[256], city_url[256];
const char *rest = msg[9] == ' ' ? msg + 10 : "";
while (*rest == ' ') rest++;
normalize_city(rest, city, sizeof city, city_url, sizeof city_url);
char *body = fetch_forecast(city_url, city);
char resp[1024];
if (body) {
snprintf(resp, sizeof resp, "%s", body);
free(body);
} else {
snprintf(resp, sizeof resp, "Forecast: could not fetch data");
}
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_MAGENTA "FORECAST %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, resp);
return 1;
}
#endif

143
cmd_gitlog.h Normal file
View file

@ -0,0 +1,143 @@
#ifndef CMD_GITLOG_H
#define CMD_GITLOG_H
#include "zynk.h"
/*
* cmd_gitlog - Handle the "!gitlog [count]" command.
*
* Displays recent git commits of the zynk project by forking a child
* process that runs "git log --oneline -n <count>". Each commit line
* is sent as a separate IRC PRIVMSG to the reply target. The commit
* count defaults to GITLOG_COUNT (5) when no argument is provided,
* and is clamped to GITLOG_MAX (20) if the user requests more.
* Rate-limited per-nick. If git is not installed or the working
* directory is not a git repository, an appropriate error message is
* sent. If no commits exist (e.g. fresh repo), reports that no commits
* were found.
*
* Parameters:
* s - The IRC session.
* msg - The raw IRC message text.
* reply_target - Channel or nick to send the response to.
* src_nick - The IRC nick of the user who issued the command.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_gitlog(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!gitlog", 7) != 0) return 0;
if (msg[7] != ' ' && msg[7] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
int count = GITLOG_COUNT;
const char *rest = msg[7] == ' ' ? msg + 8 : "";
while (*rest == ' ') rest++;
if (*rest) {
if (isdigit((unsigned char)*rest)) {
count = atoi(rest);
if (count <= 0) count = GITLOG_COUNT;
if (count > GITLOG_MAX) count = GITLOG_MAX;
while (*rest && isdigit((unsigned char)*rest)) rest++;
while (*rest == ' ') rest++;
}
}
char remote_url[512] = {0};
FILE *fp = popen("git remote get-url origin 2>/dev/null", "r");
if (fp) {
if (fgets(remote_url, sizeof remote_url, fp)) {
size_t rlen = strlen(remote_url);
while (rlen > 0 && (remote_url[rlen-1] == '\n' || remote_url[rlen-1] == '\r')) rlen--;
remote_url[rlen] = 0;
if (strncmp(remote_url, "ssh://git@", 10) == 0) {
char *host = remote_url + 10;
char *slash = strchr(host, '/');
if (slash) {
char tmp[512];
snprintf(tmp, sizeof tmp, "https://%.*s%s", (int)(slash - host), host, slash);
strncpy(remote_url, tmp, sizeof remote_url - 1);
remote_url[sizeof remote_url - 1] = 0;
}
} else if (strncmp(remote_url, "git@", 4) == 0) {
char *host = remote_url + 4;
char *path = strchr(host, ':');
if (path) {
char tmp[512];
snprintf(tmp, sizeof tmp, "https://%s%s", host, path);
strncpy(remote_url, tmp, sizeof remote_url - 1);
remote_url[sizeof remote_url - 1] = 0;
}
}
size_t ulen = strlen(remote_url);
if (ulen > 0 && remote_url[ulen-1] == '/') remote_url[--ulen] = 0;
if (ulen > 4 && strcmp(remote_url + ulen - 4, ".git") == 0) { ulen -= 4; remote_url[ulen] = 0; }
}
pclose(fp);
}
int out_pipe[2];
if (pipe(out_pipe) < 0) { irc_msg(s, reply_target, "gitlog: internal error"); return 1; }
pid_t pid = fork();
if (pid < 0) { close(out_pipe[0]); close(out_pipe[1]); irc_msg(s, reply_target, "gitlog: fork failed"); return 1; }
if (pid == 0) {
close(out_pipe[0]);
dup2(out_pipe[1], STDOUT_FILENO);
close(out_pipe[1]);
int dn = open("/dev/null", O_WRONLY);
if (dn >= 0) { dup2(dn, STDERR_FILENO); close(dn); }
char cnt[8];
snprintf(cnt, sizeof cnt, "%d", count);
execlp("git", "git", "log", "--format=%H %s", "-n", cnt, (char *)NULL);
_exit(127);
}
close(out_pipe[1]);
char buf[8192], tmp[512];
size_t len = 0;
ssize_t n;
while ((n = read(out_pipe[0], tmp, sizeof tmp - 1)) > 0) {
size_t avail = sizeof buf - 1 - len;
if (avail > 0) {
size_t copy = (size_t)n < avail ? (size_t)n : avail;
memcpy(buf + len, tmp, copy);
len += copy;
}
}
close(out_pipe[0]);
int status;
waitpid(pid, &status, 0);
while (len > 0 && (buf[len-1] == '\n' || buf[len-1] == '\r')) len--;
buf[len] = 0;
if (len == 0) {
irc_msg(s, reply_target, "gitlog: no commits found");
return 1;
}
char *line = strtok(buf, "\n");
while (line) {
while (*line == ' ') line++;
char *sp = strchr(line, ' ');
if (sp) {
*sp = 0;
char *hash = line;
char *msg = sp + 1;
while (*msg == ' ') msg++;
char short_hash[8];
strncpy(short_hash, hash, 7);
short_hash[7] = 0;
char display[512];
snprintf(display, sizeof display, "%s %s", short_hash, msg);
collapse_spaces(display);
if (*remote_url) {
char link[4096];
snprintf(link, sizeof link, "%s - %s/commit/%s", display, remote_url, hash);
irc_msg(s, reply_target, link);
} else {
irc_msg(s, reply_target, display);
}
} else {
collapse_spaces(line);
if (*line) irc_msg(s, reply_target, line);
}
line = strtok(NULL, "\n");
}
log_stamp(); fprintf(stderr, CLR_YELLOW "GITLOG %s from %s (count=%d)" CLR_RESET "\n", reply_target, src_nick, count);
return 1;
}
#endif

61
cmd_greeting_or_chat.h Normal file
View file

@ -0,0 +1,61 @@
#ifndef CMD_GREETING_OR_CHAT_H
#define CMD_GREETING_OR_CHAT_H
#include "zynk.h"
/*
* cmd_greeting_or_chat - Handle casual chat messages and direct address.
*
* Checks if the message starts with the bot's nick followed by ':', and
* if so strips that prefix to get the actual message. Matches known
* greeting/casual phrases (hi, hey, hello, bye, jojo, whatsup, waddup,
* German phrases) with hardcoded responses. If the message doesn't match
* any known phrase but was addressed to the bot by nick, forwards it to
* ai_ask() in plan mode for an AI-powered conversation response.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the sender.
*
* Returns: void.
*/
void cmd_greeting_or_chat(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
int nicklen = strlen(s->nick);
const char *cmd = msg;
if (strncasecmp(msg, s->nick, nicklen) == 0 && msg[nicklen] == ':') {
cmd = msg + nicklen + 1;
while (*cmd == ' ') cmd++;
if (!*cmd) cmd = msg;
}
char resp[1024];
int matched = 0;
if (strcasecmp(cmd, "hi") == 0 || strcasecmp(cmd, "hey") == 0 || strcasecmp(cmd, "hello") == 0) {
snprintf(resp, sizeof resp, "Hi, %s!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "bye") == 0 || strcasecmp(cmd, "goodbye") == 0) {
snprintf(resp, sizeof resp, "Bye, %s!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "jojo") == 0) {
snprintf(resp, sizeof resp, "Jo, %s!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "whatsup") == 0) {
snprintf(resp, sizeof resp, "Not much, %s!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "waddup") == 0) {
snprintf(resp, sizeof resp, "Yo %s!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "waddup yo") == 0) {
snprintf(resp, sizeof resp, "Yo!"); matched = 1;
} else if (strcasecmp(cmd, "deine muddi") == 0 || strcasecmp(cmd, "deine mutter") == 0) {
snprintf(resp, sizeof resp, "Ey, %s, lass das!", src_nick); matched = 1;
} else if (strcasecmp(cmd, "penis") == 0) {
snprintf(resp, sizeof resp, "..."); matched = 1;
}
if (matched) {
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_CYAN "CHAT %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, resp);
return;
}
if (cmd != msg) {
ai_ask(s, cmd, reply_target, src_nick, NULL);
}
}
#endif

113
cmd_help.h Normal file
View file

@ -0,0 +1,113 @@
#ifndef CMD_HELP_H
#define CMD_HELP_H
#include "zynk.h"
/*
* cmd_help - Handle the "!help" command.
*
* Responds with a compact listing of all available bot commands and
* their usage. Only lists commands that are compiled in (controlled
* by HAS_CMD_* defines). Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_help(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!help", 5) != 0) return 0;
if (msg[5] != ' ' && msg[5] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char buf[512];
char *p = buf;
size_t rem = sizeof buf;
int n;
n = snprintf(p, rem, "Commands:");
p += n; rem -= n;
#ifdef HAS_CMD_ZYNK
n = snprintf(p, rem, " !zynk <k>[=<v>],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_PING
n = snprintf(p, rem, " !ping,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_VERSION
n = snprintf(p, rem, " !version,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_UPTIME
n = snprintf(p, rem, " !uptime,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_WEATHER
n = snprintf(p, rem, " !weather [city],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_FORECAST
n = snprintf(p, rem, " !forecast [city],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_STOCK
n = snprintf(p, rem, " !stock <SYMBOL>,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_CALC
n = snprintf(p, rem, " !calc <expr>,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_TIME
n = snprintf(p, rem, " !time [tz],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_SEEN
n = snprintf(p, rem, " !seen <nick>,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_TELL
n = snprintf(p, rem, " !tell <nick> <msg>,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_GITLOG
n = snprintf(p, rem, " !gitlog [n],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_CHANGELOG
n = snprintf(p, rem, " !changelog [n],");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_AI
n = snprintf(p, rem, " !ai <question>,");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_CODE
n = snprintf(p, rem, " !code <change> (ops),");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_REBUILD
n = snprintf(p, rem, " !rebuild [pull] (ops),");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_RELOAD
n = snprintf(p, rem, " !reload (ops),");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_RESTART
n = snprintf(p, rem, " !restart (ops),");
p += n; rem -= n;
#endif
#ifdef HAS_CMD_QUIT
n = snprintf(p, rem, " !quit/!die (ops)");
p += n; rem -= n;
#endif
if (p > buf && p[-1] == ',') p[-1] = '\0';
irc_msg(s, reply_target, buf);
log_stamp(); fprintf(stderr, CLR_CYAN "HELP from %s in %s" CLR_RESET "\n", src_nick, reply_target);
return 1;
}
#endif

30
cmd_ping.h Normal file
View file

@ -0,0 +1,30 @@
#ifndef CMD_PING_H
#define CMD_PING_H
#include "zynk.h"
/*
* cmd_ping - Handle the "!ping" command.
*
* Responds with "<nick>: pong!" to confirm the bot is alive.
* Rate-limited to prevent flooding.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_ping(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!ping", 5) != 0) return 0;
if (msg[5] != ' ' && msg[5] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char pong_buf[96]; snprintf(pong_buf, sizeof pong_buf, "%s: pong!", src_nick);
irc_msg(s, reply_target, pong_buf);
log_stamp(); fprintf(stderr, CLR_YELLOW "PING from %s in %s" CLR_RESET "\n", src_nick, reply_target);
return 1;
}
#endif

33
cmd_quit.h Normal file
View file

@ -0,0 +1,33 @@
#ifndef CMD_QUIT_H
#define CMD_QUIT_H
#include "zynk.h"
/*
* cmd_quit - Handle the "!quit" and "!die" shutdown commands.
*
* Requires channel op if used in a channel. Sets the global running flag
* to 0, causing the main loop to exit gracefully. Sends a farewell
* message before shutting down.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
* tgt - The message target (channel or nick).
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_quit(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
if (strcmp(msg, "!quit") != 0 && strcmp(msg, "!die") != 0) return 0;
if (tgt[0] == '#' && !chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need op to shut me down");
return 1;
}
irc_msg(s, reply_target, "pffffff, bye. :(");
running = 0;
return 1;
}
#endif

83
cmd_rebuild.h Normal file
View file

@ -0,0 +1,83 @@
#ifndef CMD_REBUILD_H
#define CMD_REBUILD_H
#include "zynk.h"
/*
* cmd_rebuild - Handle the "!rebuild" command.
*
* Requires channel op and must be used in a channel. Optionally runs
* "git pull" if the argument "pull" is provided (e.g. "!rebuild pull").
* Always runs "make clean" and "make" in sequence. If all steps succeed,
* triggers a hot reload via reload_do(). If any step fails, reports the
* error from the build output. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
* tgt - The message target (channel or nick).
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_rebuild(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
int do_pull = (strcmp(msg, "!rebuild pull") == 0);
if (strcmp(msg, "!rebuild") != 0 && !do_pull) return 0;
if (tgt[0] != '#') {
irc_msg(s, reply_target, "!rebuild can only be used in a channel");
return 1;
}
if (!chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need op to rebuild");
return 1;
}
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
irc_msg(s, reply_target, do_pull ? "Pulling and rebuilding..." : "Rebuilding...");
log_stamp(); fprintf(stderr, CLR_GREEN "REBUILD from %s in %s (pull=%d)" CLR_RESET "\n", src_nick, reply_target, do_pull);
int status;
char errbuf[512];
if (do_pull) {
status = system("git pull 2>/tmp/zynk_rebuild_err");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
char msg_buf[640];
FILE *f = fopen("/tmp/zynk_rebuild_err", "r");
if (f) { size_t n = fread(errbuf, 1, sizeof errbuf - 1, f); errbuf[n] = 0; fclose(f); char *nl = strchr(errbuf, '\n'); if (nl) *nl = 0; }
else snprintf(errbuf, sizeof errbuf, "unknown error");
snprintf(msg_buf, sizeof msg_buf, "Rebuild failed (git pull): %s", errbuf);
irc_msg(s, reply_target, msg_buf);
log_stamp(); fprintf(stderr, CLR_RED "REBUILD git pull failed:" CLR_RESET " %s\n", errbuf);
return 1;
}
}
status = system("make clean 2>/tmp/zynk_rebuild_err");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
char msg_buf[640];
FILE *f = fopen("/tmp/zynk_rebuild_err", "r");
if (f) { size_t n = fread(errbuf, 1, sizeof errbuf - 1, f); errbuf[n] = 0; fclose(f); char *nl = strchr(errbuf, '\n'); if (nl) *nl = 0; }
else snprintf(errbuf, sizeof errbuf, "unknown error");
snprintf(msg_buf, sizeof msg_buf, "Rebuild failed (make clean): %s", errbuf);
irc_msg(s, reply_target, msg_buf);
log_stamp(); fprintf(stderr, CLR_RED "REBUILD make clean failed:" CLR_RESET " %s\n", errbuf);
return 1;
}
status = system("make 2>/tmp/zynk_rebuild_err");
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
irc_msg(s, reply_target, "Build successful, restarting...");
log_stamp(); fprintf(stderr, CLR_GREEN "REBUILD successful, restarting..." CLR_RESET "\n");
FILE *cf = fopen("/tmp/zynk_code_restart", "w");
if (cf) { fprintf(cf, "%s\n", reply_target); fclose(cf); }
reload_do(s, reply_target, 1);
} else {
FILE *f = fopen("/tmp/zynk_rebuild_err", "r");
if (f) { size_t n = fread(errbuf, 1, sizeof errbuf - 1, f); errbuf[n] = 0; fclose(f); char *nl = strchr(errbuf, '\n'); if (nl) *nl = 0; }
else snprintf(errbuf, sizeof errbuf, "unknown error");
char msg_buf[640];
snprintf(msg_buf, sizeof msg_buf, "Rebuild failed (make): %s", errbuf);
irc_msg(s, reply_target, msg_buf);
log_stamp(); fprintf(stderr, CLR_RED "REBUILD make failed:" CLR_RESET " %s\n", errbuf);
}
return 1;
}
#endif

34
cmd_reload.h Normal file
View file

@ -0,0 +1,34 @@
#ifndef CMD_RELOAD_H
#define CMD_RELOAD_H
#include "zynk.h"
/*
* cmd_reload - Handle the "!reload" command.
*
* Requires channel op if used in a channel. Rate-limited. Triggers
* reload_do() which serializes bot state, exec's a fresh copy of the
* binary, and restores state. This allows updating the bot without
* disconnecting from IRC.
*
* Parameters:
* s - The IRC session.
* msg - The raw IRC message text.
* reply_target - Channel or nick to send the response to.
* src_nick - The nick of the user who issued the command.
* tgt - The channel or nick the message was sent to.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_reload(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
if (strcmp(msg, "!reload") != 0) return 0;
if (tgt[0] == '#' && !chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need op to reload");
return 1;
}
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
reload_do(s, reply_target, 0);
return 1;
}
#endif

33
cmd_restart.h Normal file
View file

@ -0,0 +1,33 @@
#ifndef CMD_RESTART_H
#define CMD_RESTART_H
#include "zynk.h"
/*
* cmd_restart - Handle the "!restart" command.
*
* Requires channel op. Functionally identical to cmd_reload: sends a
* "Restarting..." message then triggers reload_do(). Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
* tgt - The message target (channel or nick).
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_restart(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
if (strcmp(msg, "!restart") != 0) return 0;
if (tgt[0] == '#' && !chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need op to restart me");
return 1;
}
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
irc_msg(s, reply_target, "Restarting...");
reload_do(s, reply_target, 0);
return 1;
}
#endif

58
cmd_seen.h Normal file
View file

@ -0,0 +1,58 @@
#ifndef CMD_SEEN_H
#define CMD_SEEN_H
#include "zynk.h"
/*
* cmd_seen - Handle the "!seen <nick>" command.
*
* Looks up the given nick in the seen database and reports when they
* were last seen, in which channel, and what action they were performing.
* Special-cases: checking yourself returns "You are right here, duh!",
* checking the bot returns "I am right here!", and unknown nicks return
* "I have never seen <nick>". Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw IRC message text.
* reply_target - Channel or nick to send the response to.
* src_nick - The nick of the user who issued the command.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_seen(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!seen", 5) != 0) return 0;
if (msg[5] != ' ') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
const char *rest = msg + 6;
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !seen <nick>");
return 1;
}
char target[32];
int i = 0;
while (*rest && *rest != ' ' && i < 31) target[i++] = *rest++;
target[i] = 0;
if (strcasecmp(target, src_nick) == 0) {
irc_msg(s, reply_target, "You are right here, duh!");
return 1;
}
if (strcasecmp(target, s->nick) == 0) {
irc_msg(s, reply_target, "I am right here!");
return 1;
}
char *result = seen_lookup(target);
if (result) {
irc_msg(s, reply_target, result);
free(result);
} else {
char resp[128];
snprintf(resp, sizeof resp, "I have never seen %s", target);
irc_msg(s, reply_target, resp);
}
log_stamp(); fprintf(stderr, CLR_CYAN "SEEN %s from %s in %s" CLR_RESET "\n", target, src_nick, reply_target);
return 1;
}
#endif

103
cmd_stock.h Normal file
View file

@ -0,0 +1,103 @@
#ifndef CMD_STOCK_H
#define CMD_STOCK_H
#include "zynk.h"
/*
* cmd_stock - Handle the "!stock <SYMBOL>[,SYMBOL...]" command.
*
* Fetches real-time stock quotes from Yahoo Finance's chart API for up to
* 3 comma-separated ticker symbols. Parses the JSON response to extract
* current price, previous close, change/percent, day high/low, and volume.
* Formats each symbol as "SYMB (Name): $price +/-change (+/-pct%) H:high L:low Vol:volM".
* Multiple symbols are separated by " | ". Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_stock(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!stock", 6) != 0) return 0;
if (msg[6] != ' ' && msg[6] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
const char *rest = msg[6] == ' ' ? msg + 7 : "";
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !stock <SYMBOL>[,SYMBOL...] (max 3, e.g. !stock AAPL or !stock AAPL,MSFT,GOOGL)");
return 1;
}
char buf[2048];
buf[0] = 0;
int count = 0;
char symbols[3][16];
const char *p = rest;
while (*p && count < 3) {
while (*p == ' ' || *p == ',') p++;
if (!*p) break;
int i = 0;
while (*p && *p != ' ' && *p != ',' && i < 15) symbols[count][i++] = toupper((unsigned char)*p++);
symbols[count][i] = 0;
count++;
}
for (int si = 0; si < count; si++) {
char url[512];
snprintf(url, sizeof url, "https://query1.finance.yahoo.com/v8/finance/chart/%s?interval=1d&range=1d", symbols[si]);
char *json = fetch_url(url);
if (!json) {
char tmp[128];
snprintf(tmp, sizeof tmp, "%s: fetch error", symbols[si]);
if (buf[0]) strncat(buf, " | ", sizeof buf - strlen(buf) - 1);
strncat(buf, tmp, sizeof buf - strlen(buf) - 1);
continue;
}
const char *rp_s = json_find(json, "\"regularMarketPrice\":");
const char *pc_s = json_find(json, "\"chartPreviousClose\":");
const char *hi_s = json_find(json, "\"high\":");
const char *lo_s = json_find(json, "\"low\":");
const char *vo_s = json_find(json, "\"volume\":");
const char *nm_s = json_find(json, "\"shortName\":");
if (!rp_s || !pc_s || !hi_s || !lo_s || !vo_s) {
char tmp[480];
size_t jlen = strlen(json);
if (jlen > 400) jlen = 400;
snprintf(tmp, sizeof tmp, "%s: parse error (response: %.*s)", symbols[si], (int)jlen, json);
if (buf[0]) strncat(buf, " | ", sizeof buf - strlen(buf) - 1);
strncat(buf, tmp, sizeof buf - strlen(buf) - 1);
free(json); continue;
}
double price = strtod(rp_s, NULL);
double prev_close = strtod(pc_s, NULL);
double high = strtod(hi_s, NULL);
double low = strtod(lo_s, NULL);
long volume = strtol(vo_s, NULL, 10);
double change = price - prev_close;
double pct = (prev_close != 0) ? (change / prev_close) * 100.0 : 0.0;
char name[32] = "";
if (nm_s && nm_s[0] == '"') {
nm_s++;
const char *nq = strchr(nm_s, '"');
if (nq) { size_t nl = nq - nm_s; if (nl > 31) nl = 31; memcpy(name, nm_s, nl); name[nl] = 0; }
}
char tmp[384];
if (name[0])
snprintf(tmp, sizeof tmp, "%s (%s): $%.2f %+.2f (%+.2f%%) H:%.2f L:%.2f Vol:%ldM",
symbols[si], name, price, change, pct, high, low, volume / 1000000);
else
snprintf(tmp, sizeof tmp, "%s: $%.2f %+.2f (%+.2f%%) H:%.2f L:%.2f Vol:%ldM",
symbols[si], price, change, pct, high, low, volume / 1000000);
if (buf[0]) strncat(buf, " | ", sizeof buf - strlen(buf) - 1);
strncat(buf, tmp, sizeof buf - strlen(buf) - 1);
free(json);
}
if (!buf[0]) snprintf(buf, sizeof buf, "Stock: could not fetch data");
collapse_spaces(buf);
irc_msg(s, reply_target, buf);
log_stamp(); fprintf(stderr, CLR_MAGENTA "STOCK %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, buf);
return 1;
}
#endif

53
cmd_tell.h Normal file
View file

@ -0,0 +1,53 @@
#ifndef CMD_TELL_H
#define CMD_TELL_H
#include "zynk.h"
/*
* cmd_tell - Handle the "!tell <nick> <message>" command.
*
* Stores a pending message in the tell table to be delivered to the
* target nick when they next speak. Validates that the sender is not
* trying to tell themselves something. Confirms with "Ok, I'll tell
* <nick> when they're around." Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw IRC message text.
* reply_target - Channel or nick to send the response to.
* src_nick - The nick of the user who issued the command.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_tell(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!tell", 5) != 0) return 0;
if (msg[5] != ' ') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
const char *rest = msg + 6;
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !tell <nick> <message>");
return 1;
}
char target[32];
int i = 0;
while (*rest && *rest != ' ' && i < 31) target[i++] = *rest++;
target[i] = 0;
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !tell <nick> <message>");
return 1;
}
if (strcasecmp(target, src_nick) == 0) {
irc_msg(s, reply_target, "You can't tell yourself something!");
return 1;
}
tell_add(src_nick, target, rest);
char resp[256];
snprintf(resp, sizeof resp, "Ok, I'll tell %s when they're around.", target);
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_CYAN "TELL %s from %s in %s:" CLR_RESET " -> %s: %s\n", reply_target, src_nick, reply_target, target, rest);
return 1;
}
#endif

179
cmd_time.h Normal file
View file

@ -0,0 +1,179 @@
#ifndef CMD_TIME_H
#define CMD_TIME_H
#include "zynk.h"
typedef struct { const char *name; int off; int dst_off; int dst_north; } TzEntry;
static const TzEntry tz_table[] = {
{"UTC", 0, 0, 0},
{"GMT", 0, 0, 0},
{"EST", -5*3600, -5*3600, 0},
{"CST", -6*3600, -6*3600, 0},
{"MST", -7*3600, -7*3600, 0},
{"PST", -8*3600, -8*3600, 0},
{"HST", -10*3600, -10*3600, 0},
{"US/Eastern", -5*3600, -4*3600, 1},
{"US/Central", -6*3600, -5*3600, 1},
{"US/Mountain", -7*3600, -6*3600, 1},
{"US/Pacific", -8*3600, -7*3600, 1},
{"US/Alaska", -9*3600, -8*3600, 1},
{"US/Honolulu", -10*3600, -10*3600, 0},
{"Europe/London", 0, 1*3600, 1},
{"Europe/Berlin", 1*3600, 2*3600, 1},
{"Europe/Paris", 1*3600, 2*3600, 1},
{"Europe/Moscow", 3*3600, 3*3600, 0},
{"Europe/Istanbul", 3*3600, 3*3600, 0},
{"Asia/Tokyo", 9*3600, 9*3600, 0},
{"Asia/Shanghai", 8*3600, 8*3600, 0},
{"Asia/Kolkata", 19800, 19800, 0},
{"Asia/Dubai", 4*3600, 4*3600, 0},
{"Asia/Singapore", 8*3600, 8*3600, 0},
{"Asia/Seoul", 9*3600, 9*3600, 0},
{"Asia/Hong_Kong", 8*3600, 8*3600, 0},
{"Asia/Taipei", 8*3600, 8*3600, 0},
{"Australia/Sydney", 10*3600, 11*3600, 2},
{"Australia/Melbourne", 10*3600, 11*3600, 2},
{"Australia/Brisbane", 10*3600, 10*3600, 0},
{"Australia/Perth", 8*3600, 8*3600, 0},
{"Australia/Adelaide", 930*60, 1030*60, 2},
{"Australia/Darwin", 930*60, 930*60, 0},
{"Pacific/Auckland", 12*3600, 13*3600, 2},
{"Pacific/Honolulu", -10*3600, -10*3600, 0},
{"Pacific/Fiji", 12*3600, 13*3600, 2},
{"Canada/Toronto", -5*3600, -4*3600, 1},
{"Canada/Vancouver", -8*3600, -7*3600, 1},
{"America/Sao_Paulo", -3*3600, -2*3600, 2},
{"America/Mexico_City", -6*3600, -5*3600, 1},
{"America/Argentina/Buenos_Aires", -3*3600, -3*3600, 0},
{"Africa/Cairo", 2*3600, 2*3600, 0},
{"Africa/Johannesburg", 2*3600, 2*3600, 0},
{"Africa/Lagos", 1*3600, 1*3600, 0},
{"Africa/Nairobi", 3*3600, 3*3600, 0},
{"Atlantic/Reykjavik", 0, 0, 0},
};
/*
* tz_lookup - Look up a timezone entry by name.
*
* Searches the built-in tz_table for a matching timezone name
* (case-insensitive). Returns a pointer to the entry if found, or NULL.
*
* Parameters:
* name - The timezone name to look up (e.g. "US/Eastern", "UTC").
*
* Returns: Pointer to the matching TzEntry, or NULL if not found.
*/
static const TzEntry *tz_lookup(const char *name) {
for (size_t i = 0; i < sizeof tz_table / sizeof tz_table[0]; i++)
if (strcasecmp(tz_table[i].name, name) == 0) return &tz_table[i];
return NULL;
}
/*
* tz_format - Format a timestamp with a UTC offset.
*
* Converts an epoch timestamp to a formatted date/time string using
* the given UTC offset. Writes the result as "YYYY-MM-DD HH:MM:SS" into buf.
*
* Parameters:
* epoch - The epoch timestamp to format.
* offset - UTC offset in seconds to apply before formatting.
* tzname - The timezone name (unused, reserved for future use).
* is_dst - Whether DST is in effect (unused, reserved for future use).
* buf - Output buffer for the formatted string.
* buflen - Size of the output buffer.
*
* Returns: void.
*/
static void tz_format(time_t epoch, int offset, const char *tzname, int is_dst, char *buf, size_t buflen) {
(void)tzname; (void)is_dst;
time_t local = epoch + offset;
struct tm tm;
gmtime_r(&local, &tm);
snprintf(buf, buflen, "%04d-%02d-%02d %02d:%02d:%02d",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec);
}
/*
* cmd_time - Handle the "!time [timezone]" command.
*
* Without arguments, returns the bot's local time. With a timezone
* argument, first tries the system setenv("TZ")/localtime_r() approach.
* If the system returns UTC for a non-UTC timezone (indicating missing
* tzdata), falls back to the built-in tz_table with hemisphere-aware
* DST rules. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw IRC message text.
* reply_target - Channel or nick to send the response to.
* src_nick - The nick of the user who issued the command.
*
* Returns: 1 if the command was matched, 0 otherwise.
*/
int cmd_time(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!time", 5) != 0) return 0;
if (msg[5] != ' ' && msg[5] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char resp[256];
const char *rest = msg[5] == ' ' ? msg + 6 : "";
while (*rest == ' ') rest++;
if (*rest) {
char tzname[64];
size_t tl = 0;
for (const char *p = rest; *p && tl < sizeof tzname - 1 && *p != ' '; p++)
tzname[tl++] = *p;
tzname[tl] = 0;
time_t now = time(NULL);
struct tm utc_tm;
gmtime_r(&now, &utc_tm);
char *old_tz = getenv("TZ");
char old_tz_buf[256] = "";
if (old_tz) { strncpy(old_tz_buf, old_tz, sizeof old_tz_buf - 1); old_tz_buf[sizeof old_tz_buf - 1] = 0; }
setenv("TZ", tzname, 1);
tzset();
struct tm local_tm;
localtime_r(&now, &local_tm);
if (old_tz_buf[0]) setenv("TZ", old_tz_buf, 1); else unsetenv("TZ");
tzset();
int sys_offset = (local_tm.tm_hour - utc_tm.tm_hour) * 3600 + (local_tm.tm_min - utc_tm.tm_min) * 60;
if (sys_offset > 12 * 3600) sys_offset -= 24 * 3600;
if (sys_offset < -12 * 3600) sys_offset += 24 * 3600;
int system_works = (sys_offset != 0 || strcasecmp(tzname, "UTC") == 0 || strcasecmp(tzname, "GMT") == 0);
if (system_works) {
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", &local_tm);
snprintf(resp, sizeof resp, "%s: %s", src_nick, buf);
} else {
const TzEntry *tz = tz_lookup(tzname);
if (tz) {
int off = tz->off;
if (tz->dst_north && tz->off != tz->dst_off) {
int mon = utc_tm.tm_mon + 1;
if (tz->dst_north == 1)
off = (mon >= 4 && mon <= 10) ? tz->dst_off : tz->off;
else
off = (mon >= 10 || mon <= 3) ? tz->dst_off : tz->off;
}
char buf[128];
tz_format(now, off, tzname, off != tz->off, buf, sizeof buf);
snprintf(resp, sizeof resp, "%s: %s", src_nick, buf);
} else {
snprintf(resp, sizeof resp, "%s: Unknown timezone: %s", src_nick, tzname);
}
}
} else {
time_t now = time(NULL);
struct tm *tm = localtime(&now);
char buf[64];
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", tm);
snprintf(resp, sizeof resp, "%s: %s", src_nick, buf);
}
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_YELLOW "TIME from %s in %s" CLR_RESET "\n", src_nick, reply_target);
return 1;
}
#endif

43
cmd_uptime.h Normal file
View file

@ -0,0 +1,43 @@
#ifndef CMD_UPTIME_H
#define CMD_UPTIME_H
#include "zynk.h"
/*
* cmd_uptime - Handle the "!uptime" command.
*
* Reports how long the bot has been running since it started. Formats
* the uptime as days/hours/minutes/seconds as appropriate. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_uptime(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!uptime", 7) != 0) return 0;
if (msg[7] != ' ' && msg[7] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
time_t diff = time(NULL) - start_time;
long days = diff / 86400;
long hours = (diff % 86400) / 3600;
long mins = (diff % 3600) / 60;
long secs = diff % 60;
char buf[128];
if (days > 0)
snprintf(buf, sizeof buf, "%s: up %ldd %ldh %ldm %lds", src_nick, days, hours, mins, secs);
else if (hours > 0)
snprintf(buf, sizeof buf, "%s: up %ldh %ldm %lds", src_nick, hours, mins, secs);
else if (mins > 0)
snprintf(buf, sizeof buf, "%s: up %ldm %lds", src_nick, mins, secs);
else
snprintf(buf, sizeof buf, "%s: up %lds", src_nick, secs);
irc_msg(s, reply_target, buf);
log_stamp(); fprintf(stderr, CLR_YELLOW "UPTIME from %s in %s" CLR_RESET "\n", src_nick, reply_target);
return 1;
}
#endif

30
cmd_version.h Normal file
View file

@ -0,0 +1,30 @@
#ifndef CMD_VERSION_H
#define CMD_VERSION_H
#include "zynk.h"
/*
* cmd_version - Handle the "!version" command.
*
* Responds with "zynk <VERSION>" to show the current bot version.
* Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_version(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!version", 8) != 0) return 0;
if (msg[8] != ' ' && msg[8] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char buf[96]; snprintf(buf, sizeof buf, "zynk %s", VERSION);
irc_msg(s, reply_target, buf);
log_stamp(); fprintf(stderr, CLR_BLUE "VERSION from %s in %s" CLR_RESET "\n", src_nick, reply_target);
return 1;
}
#endif

46
cmd_weather.h Normal file
View file

@ -0,0 +1,46 @@
#ifndef CMD_WEATHER_H
#define CMD_WEATHER_H
#include "zynk.h"
/*
* cmd_weather - Handle the "!weather [city]" command.
*
* Fetches current weather data from wttr.in for the specified city
* (defaults to Berlin). Normalizes the city name, fetches the one-line
* weather format, and sends the result to IRC. Rate-limited.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
*
* Returns: 1 if matched, 0 otherwise.
*/
int cmd_weather(Session *s, const char *msg, const char *reply_target, const char *src_nick) {
if (strncmp(msg, "!weather", 8) != 0) return 0;
if (msg[8] != ' ' && msg[8] != '\0') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
char city[256], city_url[256];
const char *rest = msg[8] == ' ' ? msg + 9 : "";
while (*rest == ' ') rest++;
normalize_city(rest, city, sizeof city, city_url, sizeof city_url);
char url[512];
snprintf(url, sizeof url, "https://wttr.in/%s?format=%%l:+%%t+%%C&m", city_url);
char *body = fetch_url(url);
char resp[1024];
if (body) {
char *nl = strchr(body, '\n');
if (nl) *nl = 0;
snprintf(resp, sizeof resp, "%s", body);
free(body);
} else {
snprintf(resp, sizeof resp, "Weather: could not fetch data");
}
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_MAGENTA "WEATHER %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, resp);
return 1;
}
#endif

106
cmd_zynk.h Normal file
View file

@ -0,0 +1,106 @@
#ifndef CMD_ZYNK_H
#define CMD_ZYNK_H
#include "zynk.h"
/*
* cmd_zynk - Handle the "!zynk" key-value store command.
*
* Processes three forms: bare "!zynk" (returns random entry),
* "!zynk <key>" (looks up value), and "!zynk <key> = <value>" (stores value).
* Also handles "!zynk!" prefix for overwriting existing keys (requires channel
* op). Keys are lowercased. Rate-limited. Blocked re-adds without "!" prefix
* to prevent accidental overwrites.
*
* Parameters:
* s - The IRC session.
* msg - The raw message text.
* reply_target - Channel or nick to reply to.
* src_nick - The nick of the user who sent the command.
* tgt - The message target (channel or nick).
*
* Returns: 1 if the command was matched (regardless of success), 0 otherwise.
*/
int cmd_zynk(Session *s, const char *msg, const char *reply_target, const char *src_nick, const char *tgt) {
if (strncmp(msg, "!zynk", 5) != 0) return 0;
if (msg[5] != '\0' && msg[5] != ' ' && msg[5] != '!') return 0;
if (rate_limit_check(src_nick, reply_target) < 0) return 1;
if (msg[5] == '!' && msg[6] != '\0' && msg[6] != ' ') return 0;
int force = 0;
const char *rest;
if (msg[5] == '\0') {
char *rand = db_get_random();
if (rand) {
irc_msg(s, reply_target, rand);
log_stamp(); fprintf(stderr, CLR_MAGENTA "ZYNK %s from %s:" CLR_RESET " random %s\n", reply_target, src_nick, rand);
free(rand);
} else {
irc_msg(s, reply_target, "No zynkss saved yet.");
}
return 1;
} else if (msg[5] == '!') {
if (msg[6] == '\0') {
irc_msg(s, reply_target, "Usage: !zynk! <key> = <value>");
return 1;
}
force = 1;
rest = msg + 7;
} else {
rest = msg + 6;
}
while (*rest == ' ') rest++;
if (!*rest) {
irc_msg(s, reply_target, "Usage: !zynk <key> [= <value>]");
return 1;
}
const char *eq = strstr(rest, " = ");
char resp[1024];
if (eq) {
char key[256];
size_t kl = eq - rest;
if (kl == 0) { irc_msg(s, reply_target, "Key cannot be empty"); return 1; }
if (kl >= sizeof key) kl = sizeof key - 1;
memcpy(key, rest, kl); key[kl] = 0;
for (char *p = key; *p; p++) *p = tolower((unsigned char)*p);
if (!force) {
char *existing = db_get_raw(key);
if (existing) {
free(existing);
snprintf(resp, sizeof resp, "zynk already exists (use !zynk! to overwrite)");
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_MAGENTA "ZYNK %s from %s:" CLR_RESET " blocked re-add of %s\n", reply_target, src_nick, key);
return 1;
}
} else {
if (tgt[0] == '#' && !chan_is_op(tgt, src_nick)) {
irc_msg(s, reply_target, "You need op to overwrite a zynk");
return 1;
}
}
const char *val = eq + 3;
while (*val == ' ') val++;
if (db_set(key, val, src_nick) == 0)
snprintf(resp, sizeof resp, "%s = %s (saved)", key, val);
else
snprintf(resp, sizeof resp, "Error saving %s", key);
} else {
char key[256];
snprintf(key, sizeof key, "%s", rest);
for (char *p = key; *p; p++) *p = tolower((unsigned char)*p);
char *end = key + strlen(key);
while (end > key && end[-1] == ' ') end--;
*end = 0;
char *val = db_get(key);
if (val) {
snprintf(resp, sizeof resp, "%s: %s", key, val);
free(val);
} else {
snprintf(resp, sizeof resp, "%s: not found", key);
}
}
irc_msg(s, reply_target, resp);
log_stamp(); fprintf(stderr, CLR_MAGENTA "ZYNK %s from %s:" CLR_RESET " %s\n", reply_target, src_nick, resp);
return 1;
}
#endif

593
db.h Normal file
View file

@ -0,0 +1,593 @@
#ifndef DB_H
#define DB_H
#include "zynk.h"
void tell_deliver(Session *s, const char *nick, const char *channel);
int asprintf(char **, const char *, ...);
/* ---- SQLite3 dynamic loading ---- */
void *sqlite_lib;
int (*db_open)(const char*,void**);
int (*db_exec)(void*,const char*,int(*)(void*,int,char**,char**),void*,char**);
int (*db_close)(void*);
int (*db_prepare)(void*,const char*,int,void**,const char**);
int (*db_bind_text)(void*,int,const char*,int,void(*)(void*));
int (*db_step)(void*);
int (*db_finalize)(void*);
const unsigned char *(*db_column_text)(void*,int);
void (*db_free)(void*);
int (*db_bind_int64)(void*,int,long long);
long long (*db_last_rowid)(void*);
void *db_handle;
/*
* sqlite_init_lib - Dynamically load libsqlite3 and resolve function symbols.
*
* Iterates through a list of well-known library paths (platform-dependent)
* attempting to dlopen() the SQLite3 shared object. Falls back to a generic
* dlopen() of "libsqlite3.so.0" and "libsqlite3.so" if none of the hardcoded
* paths succeed. On success, resolves all required SQLite3 function pointers
* (sqlite3_open, sqlite3_exec, sqlite3_close, sqlite3_prepare_v2,
* sqlite3_bind_text, sqlite3_step, sqlite3_finalize, sqlite3_column_text,
* sqlite3_free, sqlite3_bind_int64, sqlite3_last_insert_rowid) via dlsym
* using the LOAD_FUNC union trick for type-safe function pointer casts.
* Validates that all symbols were resolved; on failure, closes the handle.
*
* Returns: 0 on success, -1 on failure (library not found or missing symbols).
*/
static int sqlite_init_lib(void) {
static const char *paths[] = {
"/usr/lib/x86_64-linux-gnu/libsqlite3.so.0",
"/usr/lib/aarch64-linux-gnu/libsqlite3.so.0",
"/usr/lib/libsqlite3.so.0",
"/usr/lib64/libsqlite3.so.0",
"/lib/x86_64-linux-gnu/libsqlite3.so.0",
"/lib/aarch64-linux-gnu/libsqlite3.so.0",
"/lib/libsqlite3.so.0",
};
for (size_t i = 0; i < sizeof(paths)/sizeof(paths[0]); i++) {
sqlite_lib = dlopen(paths[i], RTLD_LAZY | RTLD_LOCAL);
if (sqlite_lib) break;
}
if (!sqlite_lib) {
sqlite_lib = dlopen("libsqlite3.so.0", RTLD_LAZY | RTLD_LOCAL);
if (!sqlite_lib)
sqlite_lib = dlopen("libsqlite3.so", RTLD_LAZY | RTLD_LOCAL);
}
if (!sqlite_lib) return -1;
LOAD_FUNC(db_open, "sqlite3_open");
LOAD_FUNC(db_exec, "sqlite3_exec");
LOAD_FUNC(db_close, "sqlite3_close");
LOAD_FUNC(db_prepare, "sqlite3_prepare_v2");
LOAD_FUNC(db_bind_text, "sqlite3_bind_text");
LOAD_FUNC(db_step, "sqlite3_step");
LOAD_FUNC(db_finalize, "sqlite3_finalize");
LOAD_FUNC(db_column_text,"sqlite3_column_text");
LOAD_FUNC(db_free, "sqlite3_free");
LOAD_FUNC(db_bind_int64, "sqlite3_bind_int64");
LOAD_FUNC(db_last_rowid, "sqlite3_last_insert_rowid");
if (!db_open || !db_exec || !db_close || !db_prepare ||
!db_bind_text || !db_step || !db_finalize || !db_column_text ||
!db_free || !db_bind_int64 || !db_last_rowid) {
dlclose(sqlite_lib);
sqlite_lib = NULL;
return -1;
}
return 0;
}
/*
* db_init - Initialize the SQLite3 database and create required tables.
*
* Calls sqlite_init_lib() to load the shared library, then opens (or creates)
* the "zynk.db" database file. Creates the "zynk" key-value table with columns
* (key TEXT PRIMARY KEY, value TEXT, nick TEXT, updated_at TEXT) and the
* "ai_pending" table for tracking in-flight AI queries with columns
* (id INTEGER PRIMARY KEY, question, answer, target, nick, status).
* Clears stale ai_pending rows and any lastq/lasta history entries from the
* previous session. On any error, logs to stderr and performs cleanup.
*
* Returns: 0 on success, -1 on failure.
*/
int db_init(void) {
if (sqlite_init_lib() < 0) { fprintf(stderr, CLR_RED "Cannot load libsqlite3" CLR_RESET "\n"); return -1; }
if (db_open("zynk.db", &db_handle) != SQLITE_OK) {
fprintf(stderr, CLR_RED "DB open failed" CLR_RESET "\n");
if (db_handle) { db_close(DB); db_handle = NULL; }
return -1;
}
const char *sql_zynk = "CREATE TABLE IF NOT EXISTS zynk ("
"key TEXT PRIMARY KEY, value TEXT NOT NULL, "
"nick TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')))";
char *err = NULL;
if (db_exec(DB, sql_zynk, NULL, NULL, &err) != SQLITE_OK) {
fprintf(stderr, CLR_RED "SQL: %s" CLR_RESET "\n", err ? err : "unknown error");
if (err) db_free(err);
db_close_cleanup();
return -1;
}
const char *sql_ai = "CREATE TABLE IF NOT EXISTS ai_pending ("
"id INTEGER PRIMARY KEY, question TEXT, "
"answer TEXT, target TEXT, nick TEXT, status TEXT DEFAULT 'idle')";
if (db_exec(DB, sql_ai, NULL, NULL, &err) != SQLITE_OK) {
fprintf(stderr, CLR_RED "SQL: %s" CLR_RESET "\n", err ? err : "unknown error");
if (err) db_free(err);
db_close_cleanup();
return -1;
}
const char *sql_seen = "CREATE TABLE IF NOT EXISTS seen ("
"nick TEXT PRIMARY KEY, last_seen TEXT, last_channel TEXT, last_action TEXT)";
db_exec(DB, sql_seen, NULL, NULL, NULL);
const char *sql_tell = "CREATE TABLE IF NOT EXISTS tell ("
"id INTEGER PRIMARY KEY, from_nick TEXT, to_nick TEXT, "
"message TEXT, created_at TEXT DEFAULT (datetime('now')))";
db_exec(DB, sql_tell, NULL, NULL, NULL);
const char *sql_clear_ai = "DELETE FROM ai_pending";
db_exec(DB, sql_clear_ai, NULL, NULL, NULL);
const char *sql_clear = "DELETE FROM zynk WHERE key LIKE 'lastq%' OR key LIKE 'lasta%'";
db_exec(DB, sql_clear, NULL, NULL, NULL);
return 0;
}
/*
* db_close_cleanup - Close the SQLite3 database handle if it is open.
*
* Checks if the global db_handle is non-NULL, and if so, calls sqlite3_close
* on it and resets the handle to NULL. Safe to call multiple times.
*
* Returns: void.
*/
void db_close_cleanup(void) { if (db_handle) { db_close(DB); db_handle = NULL; } }
/*
* sqlite_cleanup - Unload the dynamically loaded SQLite3 shared library.
*
* Calls dlclose() on the sqlite_lib handle if non-NULL and resets it to NULL.
* Should be called after db_close_cleanup() during shutdown.
*
* Returns: void.
*/
void sqlite_cleanup(void) { if (sqlite_lib) { dlclose(sqlite_lib); sqlite_lib = NULL; } }
/* ---- DB key-value operations ---- */
/*
* db_set - Insert or update a key-value pair in the zynk database.
*
* Uses an INSERT with ON CONFLICT upsert: if the key already exists, the
* value, nick (who set it), and updated_at timestamp are all replaced.
* The nick parameter records which IRC user created or last updated the entry.
*
* Parameters:
* key - The lookup key (stored lowercase by callers).
* value - The value string to store.
* nick - The IRC nick of the user who is setting this value.
*
* Returns: 0 on success, -1 on SQLite error.
*/
int db_set(const char *key, const char *value, const char *nick) {
void *stmt;
const char *sql = "INSERT INTO zynk(key,value,nick,updated_at)"
" VALUES(?,?,?,datetime('now'))"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value,"
" nick=excluded.nick, updated_at=datetime('now')";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
db_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
db_bind_text(stmt, 2, value, -1, SQLITE_STATIC);
db_bind_text(stmt, 3, nick, -1, SQLITE_STATIC);
int rc = db_step(stmt);
db_finalize(stmt);
return rc == SQLITE_DONE ? 0 : -1;
}
/*
* db_get_raw - Retrieve the raw value for a key without metadata.
*
* Queries the zynk table for the value associated with the given key.
* Unlike db_get(), does NOT append attribution info (nick, timestamp).
* Used internally for history rotation and existence checks.
*
* Parameters:
* key - The key to look up.
*
* Returns: A newly allocated string containing the value, or NULL if the
* key is not found. Caller must free() the result.
*/
char *db_get_raw(const char *key) {
void *stmt;
const char *sql = "SELECT value FROM zynk WHERE key=?";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *v = (const char*)db_column_text(stmt, 0);
char *r = v ? strdup(v) : NULL;
db_finalize(stmt);
return r;
}
/*
* db_get - Retrieve a key's value with attribution metadata.
*
* Queries the zynk table for the value, nick, and updated_at timestamp
* for the given key. Returns a formatted string in the form
* "value (set by nick on timestamp)".
*
* Parameters:
* key - The key to look up.
*
* Returns: A newly allocated formatted string, or NULL if the key is not
* found. Caller must free() the result.
*/
char *db_get(const char *key) {
void *stmt;
const char *sql = "SELECT value,nick,updated_at FROM zynk WHERE key=?";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *v = (const char*)db_column_text(stmt, 0);
const char *n = (const char*)db_column_text(stmt, 1);
const char *t = (const char*)db_column_text(stmt, 2);
char *r = NULL;
if (asprintf(&r, "%s (set by %s on %s)", v, n, t) < 0) r = NULL;
db_finalize(stmt);
return r;
}
/*
* db_get_random - Fetch a random key-value pair from the zynk database.
*
* Selects one random row from the zynk table, excluding internal history
* keys (those with prefix "lastq" or "lasta"). Returns a formatted string
* "key = value (by nick on timestamp)". Used when the user sends bare "zynk"
* without a key.
*
* Returns: A newly allocated formatted string, or NULL if the database is
* empty. Caller must free() the result.
*/
char *db_get_random(void) {
void *stmt;
const char *sql = "SELECT key,value,nick,updated_at FROM zynk"
" WHERE key NOT LIKE 'lastq%' AND key NOT LIKE 'lasta%'"
" ORDER BY RANDOM() LIMIT 1";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *k = (const char*)db_column_text(stmt, 0);
const char *v = (const char*)db_column_text(stmt, 1);
const char *n = (const char*)db_column_text(stmt, 2);
const char *t = (const char*)db_column_text(stmt, 3);
char *r = NULL;
if (asprintf(&r, "%s = %s (by %s on %s)", k, v, n, t) < 0) r = NULL;
db_finalize(stmt);
return r;
}
/*
* db_rotate_history - Rotate entries in the recent history ring buffer.
*
* Maintains a fixed-size (MAX_HISTORY) ring buffer of recent entries stored
* under keys "lastq01".."lastqN" or "lasta01".."lastaN". Shifts all existing
* entries by one position (N-1 <- N-2 <- ... <- 1 <- 0) and inserts the new
* value at position 0. Used to keep context for AI queries (lastq = questions,
* lasta = answers). Each entry is stored as-is without attribution metadata.
*
* Parameters:
* prefix - "lastq" for question history or "lasta" for answer history.
* value - The new entry to insert at position 0.
*
* Returns: void.
*/
void db_rotate_history(const char *prefix, const char *value) {
char *old[MAX_HISTORY];
char kbuf[MAX_HISTORY][12];
for (int i = 0; i < MAX_HISTORY; i++) {
snprintf(kbuf[i], sizeof kbuf[i], "%s%02d", prefix, i + 1);
old[i] = db_get_raw(kbuf[i]);
}
void *stmt;
const char *sql = "INSERT INTO zynk(key,value,nick,updated_at)"
" VALUES(?,?,?,datetime('now'))"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value,"
" nick=excluded.nick, updated_at=datetime('now')";
if (db_prepare(DB, sql, -1, &stmt, NULL) == SQLITE_OK) {
db_bind_text(stmt, 1, kbuf[0], -1, SQLITE_STATIC);
db_bind_text(stmt, 2, value, -1, SQLITE_STATIC);
db_bind_text(stmt, 3, "", -1, SQLITE_STATIC);
db_step(stmt);
db_finalize(stmt);
}
for (int i = 0; i < MAX_HISTORY - 1; i++) {
if (old[i] && db_prepare(DB, sql, -1, &stmt, NULL) == SQLITE_OK) {
db_bind_text(stmt, 1, kbuf[i + 1], -1, SQLITE_STATIC);
db_bind_text(stmt, 2, old[i], -1, SQLITE_STATIC);
db_bind_text(stmt, 3, "", -1, SQLITE_STATIC);
db_step(stmt);
db_finalize(stmt);
}
}
for (int i = 0; i < MAX_HISTORY; i++) free(old[i]);
}
#define db_update_lastq(v) db_rotate_history("lastq", v)
#define db_update_lasta(v) db_rotate_history("lasta", v)
/* ---- DB AI pending operations ---- */
/*
* ai_start - Create a new pending AI query record in the database.
*
* Inserts a new row into the ai_pending table with status 'pending',
* the question text, an empty answer, the target (channel or nick to
* reply to), and the requesting user's nick. Returns the auto-incremented
* row ID via the out_id parameter for later retrieval of the answer.
*
* Parameters:
* question - The user's question or code change request.
* target - The IRC target to send the reply to (channel or nick).
* nick - The IRC nick of the user who asked.
* out_id - Pointer to receive the new row's ID.
*
* Returns: 0 on success, -1 on error.
*/
int ai_start(const char *question, const char *target, const char *nick, long long *out_id) {
const char *sql = "INSERT INTO ai_pending(question,answer,target,nick,status)"
" VALUES(?,?,?,?,'pending')";
void *stmt;
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
db_bind_text(stmt, 1, question, -1, SQLITE_STATIC);
db_bind_text(stmt, 2, "", -1, SQLITE_STATIC);
db_bind_text(stmt, 3, target, -1, SQLITE_STATIC);
db_bind_text(stmt, 4, nick, -1, SQLITE_STATIC);
int rc = db_step(stmt);
db_finalize(stmt);
if (rc == SQLITE_DONE) {
*out_id = db_last_rowid(db_handle);
return 0;
}
return -1;
}
/*
* ai_get_question - Retrieve the question text for a pending AI query.
*
* Queries ai_pending by row ID and returns the stored question string.
* Used by the child process to know what to ask opencode.
*
* Parameters:
* id - The row ID returned by ai_start().
*
* Returns: A newly allocated string with the question, or NULL on error.
* Caller must free() the result.
*/
char *ai_get_question(long long id) {
void *stmt;
const char *sql = "SELECT question FROM ai_pending WHERE id=?";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_int64(stmt, 1, id);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *q = (const char*)db_column_text(stmt, 0);
char *r = q ? strdup(q) : NULL;
db_finalize(stmt);
return r;
}
/*
* ai_set_answer - Store the AI response and mark the query as done.
*
* Updates the ai_pending row with the given ID, setting the answer text
* and changing status from 'pending' to 'done'. Called by the child process
* after opencode produces output (or an error message).
*
* Parameters:
* id - The row ID returned by ai_start().
* answer - The AI response text to store.
*
* Returns: 0 on success, -1 on error.
*/
int ai_set_answer(long long id, const char *answer) {
const char *sql = "UPDATE ai_pending SET answer=?, status='done' WHERE id=?";
void *stmt;
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return -1;
db_bind_text(stmt, 1, answer, -1, SQLITE_STATIC);
db_bind_int64(stmt, 2, id);
int rc = db_step(stmt);
db_finalize(stmt);
return rc == SQLITE_DONE ? 0 : -1;
}
/*
* ai_get_answer - Retrieve the completed AI answer and its target.
*
* Queries ai_pending for a row with status='done' matching the given ID.
* Returns the answer text and the target channel/nick concatenated with
* a \x1f (unit separator) delimiter. The caller splits on this character
* to extract the answer and delivery target separately.
*
* Parameters:
* id - The row ID of the completed query.
*
* Returns: A newly allocated string "answer\x1ftarget", or NULL if the
* query is not yet done or not found. Caller must free() the result.
*/
char *ai_get_answer(long long id) {
void *stmt;
const char *sql = "SELECT answer,target FROM ai_pending WHERE id=? AND status='done'";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_int64(stmt, 1, id);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *a = (const char*)db_column_text(stmt, 0);
const char *t = (const char*)db_column_text(stmt, 1);
char *r = NULL;
if (a && t) { if (asprintf(&r, "%s\037%s", a, t) < 0) r = NULL; }
db_finalize(stmt);
return r;
}
/*
* ai_get_nick - Retrieve the requesting user's nick for a pending AI query.
*
* Queries the ai_pending table for the nick associated with the given ID.
* Used for logging when the AI child process completes.
*
* Parameters:
* id - The row ID returned by ai_start().
*
* Returns: A newly allocated string with the nick, or NULL on error.
* Caller must free() the result.
*/
char *ai_get_nick(long long id) {
void *stmt;
const char *sql = "SELECT nick FROM ai_pending WHERE id=?";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_int64(stmt, 1, id);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *n = (const char*)db_column_text(stmt, 0);
char *r = n ? strdup(n) : NULL;
db_finalize(stmt);
return r;
}
/*
* ai_reset - Delete a completed or stale AI query record from the database.
*
* Removes the ai_pending row matching the given ID. Called after the answer
* has been delivered to IRC, or after a timeout/failure to clean up.
*
* Parameters:
* id - The row ID of the query to remove.
*
* Returns: void.
*/
void ai_reset(long long id) {
const char *sql = "DELETE FROM ai_pending WHERE id=?";
void *stmt;
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return;
db_bind_int64(stmt, 1, id);
db_step(stmt);
db_finalize(stmt);
}
/* ---- DB seen/tell operations ---- */
/*
* seen_update - Record a nick's last-seen timestamp and context.
*
* Upserts a row in the seen table: inserts a new record if the nick has
* never been seen, or updates the existing record with the current UTC
* timestamp, channel, and action. Uses INSERT ... ON CONFLICT DO UPDATE.
*
* Parameters:
* nick - The IRC nick to record.
* channel - The channel where the nick was seen (or "" for DMs/QUIT).
* action - A short description of what the nick did (e.g. "spoke",
* "joined", "left"). Defaults to "spoke" if NULL.
*
* Returns: void.
*/
void seen_update(const char *nick, const char *channel, const char *action) {
void *stmt;
const char *sql = "INSERT INTO seen(nick,last_seen,last_channel,last_action)"
" VALUES(?,datetime('now','localtime'),?,?)"
" ON CONFLICT(nick) DO UPDATE SET last_seen=datetime('now','localtime'),"
" last_channel=excluded.last_channel, last_action=excluded.last_action";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return;
db_bind_text(stmt, 1, nick, -1, SQLITE_STATIC);
db_bind_text(stmt, 2, channel ? channel : "", -1, SQLITE_STATIC);
db_bind_text(stmt, 3, action ? action : "spoke", -1, SQLITE_STATIC);
db_step(stmt);
db_finalize(stmt);
}
/*
* seen_lookup - Retrieve last-seen info for a given nick.
*
* Queries the seen table for the most recent timestamp, channel, and
* action recorded for the given nick. Returns a human-readable string
* suitable for sending directly to IRC.
*
* Parameters:
* nick - The IRC nick to look up.
*
* Returns: A newly allocated string like "<nick> was last seen <action>
* <channel> <timestamp>", or NULL if the nick was never seen.
* Caller must free() the result.
*/
char *seen_lookup(const char *nick) {
void *stmt;
const char *sql = "SELECT last_seen, last_channel, last_action FROM seen WHERE nick=?";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return NULL;
db_bind_text(stmt, 1, nick, -1, SQLITE_STATIC);
if (db_step(stmt) != SQLITE_ROW) { db_finalize(stmt); return NULL; }
const char *t = (const char*)db_column_text(stmt, 0);
const char *c = (const char*)db_column_text(stmt, 1);
const char *a = (const char*)db_column_text(stmt, 2);
char *r = NULL;
if (asprintf(&r, "%s was last seen %s %s %s", nick, a ? a : "in", c ? c : "?", t ? t : "unknown") < 0) r = NULL;
db_finalize(stmt);
return r;
}
/*
* tell_add - Queue a message for later delivery to a nick.
*
* Inserts a pending message into the tell table, recording the sender,
* recipient, and message text. The message will be delivered the next
* time the recipient speaks (via tell_deliver).
*
* Parameters:
* from - The IRC nick of the sender.
* to - The IRC nick of the intended recipient.
* msg - The message text to deliver.
*
* Returns: void.
*/
void tell_add(const char *from, const char *to, const char *msg) {
void *stmt;
const char *sql = "INSERT INTO tell(from_nick,to_nick,message) VALUES(?,?,?)";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return;
db_bind_text(stmt, 1, from, -1, SQLITE_STATIC);
db_bind_text(stmt, 2, to, -1, SQLITE_STATIC);
db_bind_text(stmt, 3, msg, -1, SQLITE_STATIC);
db_step(stmt);
db_finalize(stmt);
}
/*
* tell_deliver - Deliver all pending tell messages for a nick.
*
* Queries the tell table for all messages addressed to the given nick,
* sends each one to the specified channel via irc_msg in the format
* "<nick>: message from <sender>: <text>", and deletes each row after
* delivery. Messages are delivered in insertion order (lowest ID first).
*
* Parameters:
* s - The IRC session.
* nick - The nick whose pending messages should be delivered.
* channel - The channel to send the delivery messages to.
*
* Returns: void.
*/
void tell_deliver(Session *s, const char *nick, const char *channel) {
void *stmt;
const char *sql = "SELECT id, from_nick, message FROM tell WHERE to_nick=? ORDER BY id ASC";
if (db_prepare(DB, sql, -1, &stmt, NULL) != SQLITE_OK) return;
db_bind_text(stmt, 1, nick, -1, SQLITE_STATIC);
while (db_step(stmt) == SQLITE_ROW) {
long long id = 0;
const void *idcol = db_column_text(stmt, 0);
if (idcol) id = atoll((const char*)idcol);
const char *from = (const char*)db_column_text(stmt, 1);
const char *msg = (const char*)db_column_text(stmt, 2);
char buf[1024];
snprintf(buf, sizeof buf, "%s: message from %s: %s", nick, from ? from : "?", msg ? msg : "");
irc_msg(s, channel, buf);
char dsql[128];
snprintf(dsql, sizeof dsql, "DELETE FROM tell WHERE id=%lld", id);
void *dstmt;
if (db_prepare(DB, dsql, -1, &dstmt, NULL) == SQLITE_OK) { db_step(dstmt); db_finalize(dstmt); }
}
db_finalize(stmt);
}
#endif

743
irc.h Normal file
View file

@ -0,0 +1,743 @@
#ifndef IRC_H
#define IRC_H
#include "zynk.h"
#include "db.h"
/*
* valid_irc_word - Check if a string is a single valid IRC word.
*
* A valid IRC word is non-NULL, non-empty, contains no CRLF characters,
* and contains no spaces or tabs. Used to validate IRC nicknames,
* channel names, and command arguments before database operations.
*
* Parameters:
* s - The string to validate.
*
* Returns: 1 if the string is a valid single word, 0 otherwise.
*/
int valid_irc_word(const char *s) {
if (!s || !*s || has_crlf(s)) return 0;
for (const char *p = s; *p; p++)
if (*p == ' ' || *p == '\t') return 0;
return 1;
}
/* ---- channel op tracking ---- */
ChanEntry chans[MAX_CHANS];
int nchans;
/*
* chan_get - Look up a channel entry by name.
*
* Searches the global chans[] array for a channel matching the given name
* (case-insensitive comparison). Used by all other chan_* functions to
* locate the channel record before modifying it.
*
* Parameters:
* name - The channel name (e.g. "#zynk") to look up.
*
* Returns: Pointer to the ChanEntry if found, NULL otherwise.
*/
ChanEntry *chan_get(const char *name) {
for (int i = 0; i < nchans; i++)
if (strcasecmp(chans[i].name, name) == 0) return &chans[i];
return NULL;
}
/*
* chan_get_or_add - Look up a channel entry, creating it if it doesn't exist.
*
* First tries chan_get(). If not found and there is room in the chans[] array
* (up to MAX_CHANS), allocates a new entry with the given name and an empty
* ops list. Automatically called when a JOIN message or NAMREPLY is received.
*
* Parameters:
* name - The channel name to find or register.
*
* Returns: Pointer to the ChanEntry (existing or newly created), or NULL if
* the channel array is full.
*/
ChanEntry *chan_get_or_add(const char *name) {
ChanEntry *c = chan_get(name);
if (!c && nchans < MAX_CHANS) {
c = &chans[nchans++];
snprintf(c->name, sizeof c->name, "%s", name);
c->nops = 0;
}
return c;
}
/*
* chan_is_op - Check whether a nick has operator status in a channel.
*
* Looks up the channel and searches its ops list for the given nick
* (case-insensitive). Used to gate privileged commands like !code,
* !reload, !restart, and zynk! overwrite.
*
* Parameters:
* name - The channel name.
* nick - The IRC nick to check.
*
* Returns: 1 if the nick is a channel op, 0 otherwise.
*/
int chan_is_op(const char *name, const char *nick) {
ChanEntry *c = chan_get(name);
if (!c) return 0;
for (int i = 0; i < c->nops; i++)
if (strcasecmp(c->ops[i], nick) == 0) return 1;
return 0;
}
/*
* chan_add_op - Add a nick to the operator list for a channel.
*
* Gets or creates the channel entry, then checks for duplicates before
* appending the nick to the ops array. Called when a NAMREPLY shows the
* nick with @, ~, or & prefix, or when a MODE +o is received.
*
* Parameters:
* name - The channel name.
* nick - The IRC nick to add as op.
*
* Returns: void. Silently ignores duplicates and full ops arrays.
*/
void chan_add_op(const char *name, const char *nick) {
ChanEntry *c = chan_get_or_add(name);
if (!c) return;
for (int i = 0; i < c->nops; i++)
if (strcasecmp(c->ops[i], nick) == 0) return;
if (c->nops < MAX_CHAN_OPS) {
snprintf(c->ops[c->nops], sizeof c->ops[0], "%s", nick);
c->nops++;
}
}
/*
* chan_del_op - Remove a nick from the operator list for a channel.
*
* Searches the channel's ops list for the nick (case-insensitive) and
* removes it by shifting subsequent entries down with memmove. Called on
* MODE -o, PART, KICK, and QUIT messages to keep the ops list current.
*
* Parameters:
* name - The channel name.
* nick - The IRC nick to remove from ops.
*
* Returns: void. Silently ignores if the nick is not found.
*/
void chan_del_op(const char *name, const char *nick) {
ChanEntry *c = chan_get(name);
if (!c) return;
for (int i = 0; i < c->nops; i++) {
if (strcasecmp(c->ops[i], nick) == 0) {
memmove(c->ops + i, c->ops + i + 1, (c->nops - i - 1) * sizeof c->ops[0]);
c->nops--;
return;
}
}
}
/*
* chan_rename_op - Rename a nick across all channel operator lists.
*
* Iterates all channels and replaces oldnick with newnick in every ops
* array where a match is found. Called when a NICK change message is
* received so that op tracking stays consistent across renames.
*
* Parameters:
* oldnick - The previous IRC nick.
* newnick - The new IRC nick to substitute.
*
* Returns: void.
*/
void chan_rename_op(const char *oldnick, const char *newnick) {
for (int i = 0; i < nchans; i++) {
for (int j = 0; j < chans[i].nops; j++) {
if (strcasecmp(chans[i].ops[j], oldnick) == 0) {
snprintf(chans[i].ops[j], sizeof chans[i].ops[0], "%s", newnick);
break;
}
}
}
}
/* ---- IRC protocol helpers ---- */
/*
* irc_join - Send a JOIN command to join an IRC channel.
*
* Sanitizes the channel name by stopping at spaces, colons, or CRLF
* to prevent protocol injection. Sends "JOIN #channel\r\n" via net_send.
*
* Parameters:
* s - The IRC session.
* chan - The channel name to join (e.g. "#zynk").
*
* Returns: void.
*/
void irc_join(Session *s, const char *chan) {
char tb[256]; size_t tl = 0;
if (chan) {
for (const unsigned char *p = (const unsigned char*)chan; *p && tl + 1 < sizeof tb; p++) {
if (*p == ' ' || *p == ':' || *p == '\r' || *p == '\n') break;
tb[tl++] = *p;
}
}
tb[tl] = 0;
if (tb[0]) net_send(s, "JOIN %s\r\n", tb);
}
/*
* irc_msg - Send a PRIVMSG to a target (channel or nick).
*
* Sanitizes the target name and message text. If the message contains
* any control characters (bytes < 0x20 or 0x7f), they are replaced with
* spaces to prevent IRC protocol injection. This is critical because
* AI-generated or user-provided text may contain embedded control codes.
* Sends "PRIVMSG target :text\r\n" via net_send.
*
* Parameters:
* s - The IRC session.
* t - The target (channel name or nick).
* txt - The message text to send.
*
* Returns: void.
*/
void irc_msg(Session *s, const char *t, const char *txt) {
char tb[256]; size_t tl = 0;
if (t) {
for (const unsigned char *p = (const unsigned char*)t; *p && tl + 1 < sizeof tb; p++) {
if (*p == ' ' || *p == ':' || *p == '\r' || *p == '\n') break;
tb[tl++] = *p;
}
}
tb[tl] = 0;
const char *send_txt = txt ? txt : "";
char *clean = NULL;
int need_clean = 0;
for (const unsigned char *p = (const unsigned char*)send_txt; *p; p++) {
if (*p < 0x20 || *p == 0x7f) { need_clean = 1; break; }
}
if (need_clean) {
clean = strdup(send_txt);
if (!clean) return;
for (char *p = clean; *p; p++) if ((unsigned char)*p < 0x20 || (unsigned char)*p == 0x7f) *p = ' ';
send_txt = clean;
}
if (!tb[0]) { free(clean); return; }
size_t maxtxt = 490 > tl ? 490 - tl : 0;
size_t tlen = strlen(send_txt);
if (tlen > maxtxt && maxtxt > 3) {
char *trunc = malloc(maxtxt + 1);
if (trunc) {
memcpy(trunc, send_txt, maxtxt - 3);
trunc[maxtxt - 3] = '.'; trunc[maxtxt - 2] = '.'; trunc[maxtxt - 1] = '.'; trunc[maxtxt] = 0;
send_txt = trunc;
free(clean); clean = trunc;
}
}
if (tb[0]) net_send(s, "PRIVMSG %s :%s\r\n", tb, send_txt);
free(clean);
}
/*
* irc_nick - Send a NICK command to change the bot's nickname.
*
* Sanitizes the nickname by stopping at spaces, colons, or CRLF.
* Sends "NICK newnick\r\n" via net_send. Also used on startup
* and when a nick collision (433/436) requires appending underscore.
*
* Parameters:
* s - The IRC session.
* nn - The new nickname to set.
*
* Returns: void.
*/
void irc_nick(Session *s, const char *nn) {
char tb[64]; size_t tl = 0;
if (nn) {
for (const unsigned char *p = (const unsigned char*)nn; *p && tl + 1 < sizeof tb; p++) {
if (*p == ' ' || *p == ':' || *p == '\r' || *p == '\n') break;
tb[tl++] = *p;
}
}
tb[tl] = 0;
if (tb[0]) net_send(s, "NICK %s\r\n", tb);
}
/*
* irc_pong - Send a PONG response to a server PING.
*
* Strips the leading colon from the server's ping token, sanitizes
* any control characters to spaces, and sends "PONG :token\r\n".
* Required to keep the connection alive and respond to keepalives.
*
* Parameters:
* s - The IRC session.
* r - The ping token from the server (may start with ':').
*
* Returns: void.
*/
static void irc_pong(Session *s, const char *r) {
if (*r == ':') r++;
char pb[256]; size_t pl = 0;
if (r) {
for (const unsigned char *p = (const unsigned char*)r; *p && pl + 1 < sizeof pb; p++) {
if (*p == '\r' || *p == '\n') break;
unsigned char c = *p;
if (c < 0x20 || c == 0x7f) c = ' ';
pb[pl++] = (char)c;
}
}
pb[pl] = 0;
net_send(s, "PONG :%s\r\n", pb);
}
/* ---- IRC protocol handlers ---- */
/*
* handle_privmsg - Dispatch incoming PRIVMSG to the appropriate command handler.
*
* Parses the PRIVMSG params to extract the target channel/nick and the message
* text. Ignores messages from the bot itself. Sets reply_target to the channel
* if it's a channel message, or to the sender's nick for PMs. Then tries each
* command handler in order: zynk, ping, version, help, quit, weather, forecast,
* stock, ai, code, reload, restart. If no command matches and the message was
* addressed to the bot by nick, falls through to cmd_greeting_or_chat.
*
* Parameters:
* s - The IRC session.
* params - The raw IRC params (target :message).
* src_nick - The nick of the message sender.
*
* Returns: void.
*/
static void handle_privmsg(Session *s, const char *params, const char *src_nick) {
const char *p = params;
const char *target = p;
p = strchr(p, ' '); if (!p) return;
char tgt[256]; size_t tl = p - target;
if (tl >= sizeof tgt) tl = sizeof tgt - 1;
memcpy(tgt, target, tl); tgt[tl] = 0;
while (*p == ' ') p++;
if (*p == ':') p++;
const char *msg = p;
if (strcasecmp(src_nick, s->nick) == 0) return;
seen_update(src_nick, tgt, "spoke");
tell_deliver(s, src_nick, (tgt[0] == '#') ? tgt : src_nick);
const char *reply_target = (tgt[0] == '#') ? tgt : src_nick;
#ifdef HAS_CMD_ZYNK
if (cmd_zynk(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_PING
if (cmd_ping(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_VERSION
if (cmd_version(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_UPTIME
if (cmd_uptime(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_HELP
if (cmd_help(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_QUIT
if (cmd_quit(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_WEATHER
if (cmd_weather(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_FORECAST
if (cmd_forecast(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_STOCK
if (cmd_stock(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_CALC
if (cmd_calc(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_TIME
if (cmd_time(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_SEEN
if (cmd_seen(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_TELL
if (cmd_tell(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_GITLOG
if (cmd_gitlog(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_CHANGELOG
if (cmd_changelog(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_AI
if (cmd_ai(s, msg, reply_target, src_nick)) return;
#endif
#ifdef HAS_CMD_CODE
if (cmd_code(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_REBUILD
if (cmd_rebuild(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_RELOAD
if (cmd_reload(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_RESTART
if (cmd_restart(s, msg, reply_target, src_nick, tgt)) return;
#endif
#ifdef HAS_CMD_GREETING_OR_CHAT
cmd_greeting_or_chat(s, msg, reply_target, src_nick);
#endif
if (msg[0] == '!')
irc_msg(s, reply_target, "Not implemented...");
}
/*
* handle_namreply - Process RPL_NAMREPLY (353) to populate channel op lists.
*
* Parses the names list from the server's NAMREPLY, which contains nicks
* prefixed with status symbols: @ = channel op, ~ = owner, & = admin,
* % = halfop. For each nick with op-like prefix, adds it to the channel's
* op tracking list via chan_add_op(). This is how the bot discovers existing
* ops when it first joins a channel.
*
* Parameters:
* s - The IRC session.
* params - The raw IRC params (nick type #channel :names...).
*
* Returns: void.
*/
static void handle_namreply(Session *s, const char *params) {
(void)s;
char our[32], _chan[64];
const char *p = params;
/* params: "<nick> <type> <chan> :<nicks>" */
if (sscanf(p, "%31s %*s %63s", our, _chan) < 2) return;
p = strchr(p, ':');
if (!p) return;
p++;
char buf[32];
while (*p) {
while (*p == ' ') p++;
if (!*p) break;
int is_op = 0;
while (*p && (*p == '@' || *p == '~' || *p == '&' || *p == '%')) {
if (*p == '@' || *p == '~' || *p == '&') is_op = 1;
p++;
}
int i = 0;
while (*p && *p != ' ') {
if (i < 31) buf[i++] = *p;
p++;
}
buf[i] = 0;
if (is_op && buf[0]) chan_add_op(_chan, buf);
}
}
/*
* handle_mode - Process MODE changes to track channel op additions/removals.
*
* Parses MODE messages like "#chan +o nick" or "#chan -o nick". When +o is
* set, adds the nick to the channel's op list. When -o is set, removes it.
* Only processes channel modes that affect operator status.
*
* Parameters:
* s - The IRC session.
* params - The raw IRC params (channel modes [nick]).
*
* Returns: void.
*/
static void handle_mode(Session *s, const char *params) {
(void)s;
char _chan[64], _modes[32], _nick[32];
if (sscanf(params, "%63s %31s %31s", _chan, _modes, _nick) < 3) return;
if (_chan[0] == '#') {
if (_modes[0] == '+' && strchr(_modes, 'o'))
chan_add_op(_chan, _nick);
else if (_modes[0] == '-' && strchr(_modes, 'o'))
chan_del_op(_chan, _nick);
}
}
/*
* handle_join - Process JOIN messages to register new channels.
*
* When any user (including the bot) joins a channel, registers the channel
* in the tracking array via chan_get_or_add(). This ensures the channel
* exists in the ops tracking structure even before NAMREPLY arrives.
*
* Parameters:
* s - The IRC session.
* params - The raw IRC params (:#channel).
*
* Returns: void.
*/
static void handle_join(Session *s, const char *params) {
(void)s;
const char *p = params;
if (*p == ':') p++;
char _chan[64];
if (sscanf(p, "%63s", _chan) == 1)
chan_get_or_add(_chan);
}
/*
* handle_part - Process PART messages to clean up channel op tracking.
*
* When a user parts a channel, removes them from that channel's op list
* via chan_del_op(). Prevents stale op entries from accumulating.
*
* Parameters:
* params - The raw IRC params (#channel [message]).
* src_nick - The nick of the user who parted.
*
* Returns: void.
*/
static void handle_part(const char *params, const char *src_nick) {
char _chan[64];
if (sscanf(params, "%63s", _chan) >= 1)
chan_del_op(_chan, src_nick);
seen_update(src_nick, _chan, "left");
}
/*
* handle_kick - Process KICK messages to remove kicked users from op lists.
*
* When a user is kicked from a channel, removes them from that channel's
* op list via chan_del_op(). Prevents tracking a user who is no longer
* in the channel.
*
* Parameters:
* params - The raw IRC params (#channel victim [message]).
*
* Returns: void.
*/
static void handle_kick(const char *params) {
char _chan[64], _victim[32];
if (sscanf(params, "%63s %31s", _chan, _victim) >= 2)
chan_del_op(_chan, _victim);
}
/*
* handle_quit_net - Process QUIT messages to clean up op tracking.
*
* When any user quits the network, removes them from all channel op lists
* via chan_del_op(). Iterates all tracked channels.
*
* Parameters:
* src_nick - The nick of the user who quit.
*
* Returns: void.
*/
static void handle_quit_net(const char *src_nick) {
for (int i = 0; i < nchans; i++)
chan_del_op(chans[i].name, src_nick);
seen_update(src_nick, NULL, "quit");
}
/*
* handle_nick_change - Process NICK changes to update op tracking and bot state.
*
* Extracts the old nick from the message prefix, gets the new nick from
* params, then renames the nick across all channel op lists via
* chan_rename_op(). If the bot itself changed its nick (e.g. after a
* 433 collision), updates s->nick to match. Validates the new nick for
* safety (no CRLF, no control characters).
*
* Parameters:
* s - The IRC session.
* prefix - The IRC prefix containing the old nick (nick!user@host).
* params - The IRC params (:newnick).
*
* Returns: void.
*/
static void handle_nick_change(Session *s, const char *prefix, const char *params) {
char src_nick[64] = "";
const char *ex = strchr(prefix, '!');
size_t nlen = ex ? (size_t)(ex - prefix) : strlen(prefix);
if (nlen >= sizeof src_nick) nlen = sizeof src_nick - 1;
memcpy(src_nick, prefix, nlen); src_nick[nlen] = 0;
const char *newnick = params;
if (*newnick == ':') newnick++;
if (!*newnick || has_crlf(newnick) || has_ctl(newnick)) return;
chan_rename_op(src_nick, newnick);
if (strcasecmp(src_nick, s->nick) == 0) {
strncpy(s->nick, newnick, sizeof s->nick - 1);
s->nick[sizeof s->nick - 1] = 0;
}
}
/*
* handle_error_codes - Handle numeric IRC error codes and ERROR messages.
*
* Handles: 464 (password rejected - shuts down), 433/436 (nick collision -
* appends underscore to nick and retries), and ERROR (logs the message).
* These are server-level errors that require special handling beyond normal
* command dispatch.
*
* Parameters:
* s - The IRC session.
* cmd_buf - The IRC command/numeric as a string.
* params - The IRC params.
*
* Returns: void.
*/
static void handle_error_codes(Session *s, const char *cmd_buf, const char *params) {
if (strcmp(cmd_buf, "464") == 0) {
log_stamp(); fprintf(stderr, CLR_RED "Password rejected" CLR_RESET "\n"); running = 0; return;
}
if (strcmp(cmd_buf, "433") == 0 || strcmp(cmd_buf, "436") == 0) {
char newnick[33];
snprintf(newnick, sizeof newnick, "%s_", s->nick);
irc_nick(s, newnick);
strncpy(s->nick, newnick, sizeof s->nick - 1);
s->nick[sizeof s->nick - 1] = 0;
return;
}
if (strcmp(cmd_buf, "ERROR") == 0) {
const char *m = params;
if (*m == ':') m++;
log_stamp(); fprintf(stderr, CLR_RED "ERROR:" CLR_RESET " %s\n", m);
return;
}
}
/* ---- IRC dispatcher ---- */
/*
* irc_handle - Parse and dispatch a single complete IRC protocol message.
*
* Parses the IRC message format (:prefix COMMAND params :trailing) into
* its components. Handles server keepalives (PING/PONG), connection
* establishment (001), MOTD end (376/422 triggers channel joins), and
* routes numeric replies and commands to their respective handler
* functions. Extracts the source nick from the prefix for PRIVMSG,
* JOIN, PART, KICK, QUIT, and NICK commands. Also handles the code
* restart notification on MOTD end.
*
* Parameters:
* s - The IRC session.
* raw - The complete IRC line (without trailing \r\n).
*
* Returns: void.
*/
static void irc_handle(Session *s, const char *raw) {
if (!raw || !*raw) return;
const char *prefix = NULL, *cmd = raw;
char pfx_buf[512], cmd_buf[64];
pfx_buf[0] = cmd_buf[0] = 0;
if (*raw == ':') {
raw++;
const char *sp = strchr(raw, ' ');
if (!sp) return;
size_t plen = sp - raw;
if (plen >= sizeof pfx_buf) plen = sizeof pfx_buf - 1;
memcpy(pfx_buf, raw, plen); pfx_buf[plen] = 0;
prefix = pfx_buf;
cmd = sp + 1;
while (*cmd == ' ') cmd++;
if (!*cmd) return;
}
const char *params = strchr(cmd, ' ');
if (params) {
size_t clen = params - cmd;
if (clen >= sizeof cmd_buf) clen = sizeof cmd_buf - 1;
memcpy(cmd_buf, cmd, clen); cmd_buf[clen] = 0;
while (*params == ' ') params++;
} else {
size_t cl2 = strlen(cmd);
if (cl2 >= sizeof cmd_buf) cl2 = sizeof cmd_buf - 1;
memcpy(cmd_buf, cmd, cl2); cmd_buf[cl2] = 0;
params = "";
}
if (strcmp(cmd_buf, "PING") == 0) { irc_pong(s, params); return; }
char src_nick[64] = "";
if (prefix) {
const char *ex = strchr(prefix, '!');
size_t nlen = ex ? (size_t)(ex - prefix) : strlen(prefix);
if (nlen >= sizeof src_nick) nlen = sizeof src_nick - 1;
memcpy(src_nick, prefix, nlen); src_nick[nlen] = 0;
}
if (strcmp(cmd_buf, "001") == 0) {
s->connected = 1;
log_stamp(); fprintf(stderr, CLR_GREEN "Connected to %s" CLR_RESET "\n", s->host);
return;
}
if (strcmp(cmd_buf, "376") == 0 || strcmp(cmd_buf, "422") == 0) {
log_stamp(); fprintf(stderr, CLR_GREEN "MOTD end, joining channels..." CLR_RESET "\n");
char *copy = strdup(s->auto_join);
if (copy) {
char *save;
for (char *tok = strtok_r(copy, ",", &save); tok; tok = strtok_r(NULL, ",", &save)) {
while (*tok == ' ') tok++;
char *end = tok + strlen(tok);
while (end > tok && end[-1] == ' ') end--;
*end = 0;
if (*tok) { irc_join(s, tok); log_stamp(); fprintf(stderr, CLR_GREEN "Joining %s" CLR_RESET "\n", tok); }
}
free(copy);
}
if (s->code_restart) {
s->code_restart = 0;
char *c2 = strdup(s->auto_join);
if (c2) {
char *sv;
for (char *t = strtok_r(c2, ",", &sv); t; t = strtok_r(NULL, ",", &sv)) {
while (*t == ' ') t++;
char *e = t + strlen(t);
while (e > t && e[-1] == ' ') e--;
*e = 0;
if (*t) irc_msg(s, t, "Code update done, I'm back!");
}
free(c2);
}
}
return;
}
if (strcmp(cmd_buf, "PRIVMSG") == 0) { handle_privmsg(s, params, src_nick); return; }
if (strcmp(cmd_buf, "353") == 0) { handle_namreply(s, params); return; }
if (strcmp(cmd_buf, "MODE") == 0) { handle_mode(s, params); return; }
if (strcmp(cmd_buf, "JOIN") == 0 && prefix) { handle_join(s, params); return; }
if (strcmp(cmd_buf, "PART") == 0 && prefix) { handle_part(params, src_nick); return; }
if (strcmp(cmd_buf, "KICK") == 0 && prefix) { handle_kick(params); return; }
if (strcmp(cmd_buf, "QUIT") == 0 && prefix) { handle_quit_net(src_nick); return; }
if (strcmp(cmd_buf, "NICK") == 0 && prefix) { handle_nick_change(s, prefix, params); return; }
handle_error_codes(s, cmd_buf, params);
}
/* ---- IRC line parser ---- */
/*
* irc_feed - Buffer incoming data and dispatch complete IRC lines.
*
* Accumulates raw bytes into the session's read buffer (s->rbuf) until
* a newline is found. On \n, NUL-terminates the buffer and passes the
* complete line to irc_handle(). Skips \r characters. Prevents buffer
* overflow by checking against RBUF_SZ. This implements the line-based
* protocol parsing required by IRC.
*
* Parameters:
* s - The IRC session with rbuf and rlen state.
* data - Raw bytes from the network read.
* len - Number of bytes in data.
*
* Returns: void.
*/
static void irc_feed(Session *s, const char *data, int len) {
for (int i = 0; i < len; i++) {
char c = data[i];
if (c == '\r') continue;
if (c == '\n') {
s->rbuf[s->rlen] = 0;
if (s->rlen > 0) irc_handle(s, s->rbuf);
s->rlen = 0;
} else {
if (s->rlen < RBUF_SZ - 1) s->rbuf[s->rlen++] = c;
}
}
}
#endif

135
net.h Normal file
View file

@ -0,0 +1,135 @@
#ifndef NET_H
#define NET_H
#include "zynk.h"
/* ---- network I/O ---- */
/*
* net_connect - Establish a TCP connection to a remote host.
*
* Performs DNS resolution via getaddrinfo() with AI_ADDRCONFIG (restricts
* to address families available on the local system), then iterates through
* resolved addresses trying socket()+connect() on each. The socket is
* non-blocking by default since this function doesn't set O_NONBLOCK.
*
* Parameters:
* host - The hostname or IP address to connect to.
* port - The TCP port number.
*
* Returns: A connected file descriptor on success, or -1 on failure.
*/
int net_connect(const char *host, int port) {
struct addrinfo hints, *ai;
memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_ADDRCONFIG;
char pbuf[16];
snprintf(pbuf, sizeof pbuf, "%d", port);
int err = getaddrinfo(host, pbuf, &hints, &ai);
if (err) { fprintf(stderr, CLR_RED "DNS:" CLR_RESET " %s\n", gai_strerror(err)); return -1; }
int fd = -1;
for (struct addrinfo *rp = ai; 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(ai);
return fd;
}
/*
* net_read - Read data from the session's socket (TLS-aware).
*
* If the session has an active SSL connection, reads via SSL_read();
* otherwise uses plain read(). Translates SSL_ERROR_WANT_READ/WANT_WRITE
* into errno=EAGAIN for consistent non-blocking behavior. Returns 0 on
* clean TLS shutdown (SSL_ERROR_ZERO_RETURN).
*
* Parameters:
* s - The IRC session with fd and optional SSL context.
* buf - Destination buffer for the read data.
* size - Maximum number of bytes to read.
*
* Returns: Number of bytes read, 0 on EOF/shutdown, -1 on error (check
* errno for EAGAIN to distinguish transient from fatal errors).
*/
int net_read(Session *s, char *buf, int size) {
if (s->ssl) {
int r = SSL_read(s->ssl, buf, size);
if (r > 0) return r;
int e = SSL_get_error(s->ssl, r);
if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) { errno = EAGAIN; return -1; }
if (e == SSL_ERROR_ZERO_RETURN) return 0;
errno = EIO;
return -1;
}
return read(s->fd, buf, size);
}
/*
* net_write - Write data to the session's socket (TLS-aware).
*
* If the session has an active SSL connection, writes via SSL_write();
* otherwise uses plain write(). Translates SSL_ERROR_WANT_READ/WANT_WRITE
* into errno=EAGAIN for consistent non-blocking behavior.
*
* Parameters:
* s - The IRC session with fd and optional SSL context.
* buf - Source buffer containing data to send.
* size - Number of bytes to write.
*
* Returns: Number of bytes written on success, -1 on error.
*/
int net_write(Session *s, const char *buf, int size) {
if (s->ssl) {
int r = SSL_write(s->ssl, buf, size);
if (r > 0) return r;
int e = SSL_get_error(s->ssl, r);
if (e == SSL_ERROR_WANT_READ || e == SSL_ERROR_WANT_WRITE) { errno = EAGAIN; return -1; }
return -1;
}
return write(s->fd, buf, size);
}
/*
* net_send - Format and send an IRC protocol message with retry logic.
*
* Uses vsnprintf to format the message into a 4096-byte stack buffer, then
* writes it via net_write() in a loop to handle partial writes. Retries on
* EINTR (signal interruption) and EAGAIN (would-block). The caller should
* include "\r\n" in the format string per IRC protocol requirements.
*
* Parameters:
* s - The IRC session.
* fmt - printf-style format string for the message.
* ... - Format arguments.
*
* Returns: Number of bytes sent on success, 0 if nothing to send,
* -1 on truncation or write failure.
*/
int net_send(Session *s, const char *fmt, ...) {
char buf[4096];
va_list ap;
va_start(ap, fmt);
int n = vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
if (n <= 0) return 0;
if ((size_t)n >= sizeof buf) return -1;
int left = n;
const char *p = buf;
while (left > 0) {
int w = net_write(s, p, left);
if (w < 0) {
if (errno == EINTR || errno == EAGAIN) continue;
return -1;
}
if (w == 0) return -1;
left -= w; p += w;
}
return n;
}
#endif

99
tls.h Normal file
View file

@ -0,0 +1,99 @@
#ifndef TLS_H
#define TLS_H
#include <openssl/ssl.h>
#include "zynk.h"
extern int tls_verify;
/* ---- TLS ---- */
static SSL_CTX *tls_ctx;
/*
* tls_init - Initialize the OpenSSL library and create a TLS context.
*
* Calls SSL_library_init() and SSL_load_error_strings() for OpenSSL setup,
* creates a new SSL_CTX using the TLS client method, loads default CA
* certificates for peer verification, and configures the verify mode
* based on the global tls_verify flag.
*
* Returns: 0 on success, -1 if context creation fails.
*/
static int tls_init(void) {
SSL_library_init();
SSL_load_error_strings();
tls_ctx = SSL_CTX_new(TLS_client_method());
if (!tls_ctx) return -1;
SSL_CTX_set_default_verify_paths(tls_ctx);
SSL_CTX_set_verify(tls_ctx, tls_verify ? SSL_VERIFY_PEER : SSL_VERIFY_NONE, NULL);
return 0;
}
/*
* tls_handshake - Perform a TLS handshake on an established TCP connection.
*
* Creates a new SSL object, associates it with the session's file descriptor
* and hostname (for SNI), then performs the handshake. Temporarily disables
* O_NONBLOCK on the fd since SSL_connect needs blocking I/O, then restores
* the original flags. If tls_verify is enabled, validates the server's
* certificate against the system CA store.
*
* Parameters:
* s - The IRC session with a valid fd and host.
*
* Returns: 0 on successful handshake, -1 on failure (SSL object is freed).
*/
static int tls_handshake(Session *s) {
if (!tls_ctx) return -1;
s->ssl = SSL_new(tls_ctx);
if (!s->ssl) return -1;
SSL_set_fd(s->ssl, s->fd);
SSL_set_tlsext_host_name(s->ssl, s->host);
if (tls_verify) SSL_set1_host(s->ssl, s->host);
int fl = fcntl(s->fd, F_GETFL, 0);
fcntl(s->fd, F_SETFL, fl & ~O_NONBLOCK);
int r = SSL_connect(s->ssl);
fcntl(s->fd, F_SETFL, fl | O_NONBLOCK);
if (r != 1) { SSL_free(s->ssl); s->ssl = NULL; return -1; }
if (tls_verify && SSL_get_verify_result(s->ssl) != X509_V_OK) {
SSL_free(s->ssl); s->ssl = NULL; return -1;
}
return 0;
}
/*
* tls_cleanup - Shut down and free the SSL connection for a session.
*
* Performs SSL_shutdown() to send the TLS close_notify alert (if the
* connection is still active), then frees the SSL object. Safe to call
* when s->ssl is NULL (no-op).
*
* Parameters:
* s - The IRC session whose SSL connection to tear down.
*
* Returns: void.
*/
static void tls_cleanup(Session *s) {
if (!s->ssl) return;
SSL_shutdown(s->ssl);
SSL_free(s->ssl);
s->ssl = NULL;
}
/*
* tls_ctx_cleanup - Free the global TLS context.
*
* Calls SSL_CTX_free() on the global tls_ctx and sets it to NULL.
* Should be called during shutdown after all sessions are cleaned up.
*
* Returns: void.
*/
static void tls_ctx_cleanup(void) {
if (tls_ctx) {
SSL_CTX_free(tls_ctx);
tls_ctx = NULL;
}
}
#endif

3294
zynk.c

File diff suppressed because it is too large Load diff

192
zynk.h Normal file
View file

@ -0,0 +1,192 @@
#ifndef ZYNK_H
#define ZYNK_H
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <time.h>
#include <stdarg.h>
#include <ctype.h>
#include <sys/socket.h>
#include <netdb.h>
#include <dlfcn.h>
#include <sys/wait.h>
#include <openssl/ssl.h>
#ifndef ZYNK_VERSION
#define ZYNK_VERSION "0.40.0"
#endif
#define VERSION ZYNK_VERSION
#define DEFAULT_SERVER "irc.libera.chat"
#define OPENCODE_BIN "/usr/bin/opencode"
#define CLR_RESET "\033[0m"
#define CLR_DIM "\033[2m"
#define CLR_RED "\033[31m"
#define CLR_GREEN "\033[32m"
#define CLR_YELLOW "\033[33m"
#define CLR_BLUE "\033[34m"
#define CLR_MAGENTA "\033[35m"
#define CLR_CYAN "\033[36m"
enum {
RBUF_SZ = 16384,
DEF_PORT = 6697,
POLL_MS = 100,
AI_TIMEOUT = 600,
MAX_CHANS = 32,
MAX_CHAN_OPS = 64,
RATE_LIMIT_WINDOW = 10,
RATE_LIMIT_MAX = 8,
MAX_HISTORY = 100,
GITLOG_COUNT = 1,
GITLOG_MAX = 10,
CHANGELOG_COUNT = 1,
CHANGELOG_MAX = 10,
};
typedef struct {
int fd;
char host[256];
int port;
char nick[32];
char user[32];
char real[256];
char pass[256];
char auto_join[256];
int connected;
char rbuf[16384];
int rlen;
SSL *ssl;
int use_tls;
int code_restart;
} Session;
/* SQLite3 dynamic loading */
extern void *sqlite_lib;
extern int (*db_open)(const char*,void**);
extern int (*db_exec)(void*,const char*,int(*)(void*,int,char**,char**),void*,char**);
extern int (*db_close)(void*);
extern int (*db_prepare)(void*,const char*,int,void**,const char**);
extern int (*db_bind_text)(void*,int,const char*,int,void(*)(void*));
extern int (*db_step)(void*);
extern int (*db_finalize)(void*);
extern const unsigned char *(*db_column_text)(void*,int);
extern void (*db_free)(void*);
extern int (*db_bind_int64)(void*,int,long long);
extern long long (*db_last_rowid)(void*);
#define LOAD_FUNC(var, name) do { \
union { void *p; typeof(var) f; } _u; \
_u.p = dlsym(sqlite_lib, name); \
var = _u.f; \
} while(0)
#define SQLITE_OK 0
#define SQLITE_ERROR 1
#define SQLITE_DONE 101
#define SQLITE_ROW 100
#define SQLITE_INTEGER 1
#define SQLITE_FLOAT 2
#define SQLITE_TEXT 3
#define SQLITE_BLOB 4
#define SQLITE_NULL 5
#define SQLITE_STATIC ((void(*)(void*))0)
#define SQLITE_TRANSIENT ((void(*)(void*))-1)
typedef struct { void *ptr; } sqlite3;
#define DB ((sqlite3*)db_handle)
extern void *db_handle;
/* DB key-value operations */
int db_set(const char *key, const char *value, const char *nick);
char *db_get_raw(const char *key);
char *db_get(const char *key);
char *db_get_random(void);
void db_rotate_history(const char *prefix, const char *value);
#define db_update_lastq(v) db_rotate_history("lastq", v)
#define db_update_lasta(v) db_rotate_history("lasta", v)
/* DB AI pending operations */
int ai_start(const char *question, const char *target, const char *nick, long long *out_id);
char *ai_get_question(long long id);
int ai_set_answer(long long id, const char *answer);
char *ai_get_answer(long long id);
char *ai_get_nick(long long id);
void ai_reset(long long id);
/* DB seen/tell operations */
void seen_update(const char *nick, const char *channel, const char *action);
char *seen_lookup(const char *nick);
void tell_add(const char *from, const char *to, const char *msg);
/* Channel op tracking */
typedef struct { char name[64]; char ops[MAX_CHAN_OPS][32]; int nops; } ChanEntry;
extern ChanEntry chans[MAX_CHANS];
extern int nchans;
ChanEntry *chan_get(const char *name);
ChanEntry *chan_get_or_add(const char *name);
int chan_is_op(const char *name, const char *nick);
void chan_add_op(const char *name, const char *nick);
void chan_del_op(const char *name, const char *nick);
void chan_rename_op(const char *oldnick, const char *newnick);
/* Rate limiting */
int rate_limit_check(const char *nick, const char *channel);
/* Text utilities */
void strip_ai_phrases(char *s);
void strip_formatting(char *s);
void strip_opencode_header(char *s);
int has_crlf(const char *s);
int has_ctl(const char *s);
int valid_irc_word(const char *s);
void normalize_city(const char *in, char *display, size_t dsz, char *url, size_t usz);
void collapse_spaces(char *s);
const char *json_find(const char *haystack, const char *key);
/* Curl / fetch */
char *fetch_url(const char *url);
char *fetch_forecast(const char *city_url, const char *city_display);
/* Logging */
void log_stamp(void);
/* Signal / globals */
extern volatile sig_atomic_t running;
extern const char *g_opencode_bin;
extern volatile pid_t pending_ai_pid;
extern volatile long long pending_ai_id;
extern time_t start_time;
/* IRC protocol helpers */
void irc_msg(Session *s, const char *t, const char *txt);
void irc_join(Session *s, const char *chan);
void irc_nick(Session *s, const char *nn);
int net_send(Session *s, const char *fmt, ...);
/* Hot reload */
void reload_do(Session *s, const char *reply_target, int exec_new);
/* AI pipeline */
void ai_ask(Session *s, const char *question, const char *reply_target, const char *src_nick, const char *agent);
/* Compile helper */
int try_compile_and_restart(Session *s, const char *target);
/* DB init/cleanup */
int db_init(void);
void db_close_cleanup(void);
void sqlite_cleanup(void);
/* AI child task */
void ai_child_task(Session *s, long long id, const char *agent);
#endif