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:
parent
184d4c4000
commit
739e3622b5
34 changed files with 4317 additions and 3687 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,4 +1,6 @@
|
|||
.idea/
|
||||
.junie/
|
||||
build/
|
||||
zynk
|
||||
zynk.db
|
||||
*.so
|
||||
|
|
|
|||
24
AGENTS.md
24
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
33
CHANGELOG.md
33
CHANGELOG.md
|
|
@ -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
89
CMakeLists.txt
Normal 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)
|
||||
34
Makefile
34
Makefile
|
|
@ -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
|
||||
83
README.md
83
README.md
|
|
@ -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
346
ai.h
Normal 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
39
cmd_ai.h
Normal 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
82
cmd_calc.h
Normal 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
107
cmd_changelog.h
Normal 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
53
cmd_code.h
Normal 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
114
cmd_forecast.h
Normal 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
143
cmd_gitlog.h
Normal 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
61
cmd_greeting_or_chat.h
Normal 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
113
cmd_help.h
Normal 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
30
cmd_ping.h
Normal 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
33
cmd_quit.h
Normal 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
83
cmd_rebuild.h
Normal 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
34
cmd_reload.h
Normal 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
33
cmd_restart.h
Normal 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
58
cmd_seen.h
Normal 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
103
cmd_stock.h
Normal 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
53
cmd_tell.h
Normal 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
179
cmd_time.h
Normal 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
43
cmd_uptime.h
Normal 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
30
cmd_version.h
Normal 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
46
cmd_weather.h
Normal 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
106
cmd_zynk.h
Normal 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
593
db.h
Normal 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
743
irc.h
Normal 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
135
net.h
Normal 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
99
tls.h
Normal 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
|
||||
192
zynk.h
Normal file
192
zynk.h
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue