Fix irc-privmsg/irc-notice: force :trailing on the last param
The 'needs-colon' heuristic in make-irc-command dropped the : for short spaceless bodies like 'connected.', producing wire that strict parsers treat as a middle parameter — leaving irc-message-trailing empty and the message body lost. Live break: apiary's reconnect-presence broadcast (PRIVMSG #hive connected.) round-tripped through enclave-server's privmsg-forward (which uses make-irc-command/tags via params:) and arrived at peers with empty text.
Fix: irc-privmsg and irc-notice now use a new make-trailing-command helper that unconditionally emits ':' on the last param. Behavior of make-irc-command itself is unchanged — MODE/NICK/PING and friends still emit unambiguous single-token last params without a colon, since those structurally identify operands rather than human text.
40 message tests + 158-test suite green.
CHANGELOG.md | 10 ++++++++++
package.sgl | 2 +-
src/sigil/irc/connection.sgl | 17 +++++++++++++----
src/sigil/irc/message.sgl | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
test/test-message.sgl | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 131 insertions(+), 5 deletions(-)CHANGELOG.mdmodified
The format is based on [Keep a Changelog](https://keepachangelog.com/),and this project adheres to [Semantic Versioning](https://semver.org/).## [0.10.1] - 2026-04-29### Fixed- `irc-privmsg` and `irc-notice` now always emit the body as a `:trailing` parameter, even for short spaceless single-token bodies (e.g. `connected.`). The earlier behavior — `make-irc-command`'s "needs-colon" heuristic dropped the `:` for any body without an internal space or leading colon — produced wire that strict receivers parsed as a middle parameter, leaving `irc-message-trailing` empty and the message body lost. Surfaced in apiary's reconnect-presence broadcast (`PRIVMSG #hive connected.`) round-tripping through enclave-server and arriving at peers with empty text.### Added- `make-trailing-command` — public sibling of `make-irc-command` that unconditionally emits a `:` on the last argument. Use it for any command whose final parameter is human/agent text (PRIVMSG, NOTICE, TOPIC, QUIT, PART message, KICK reason). `make-irc-command` retains its existing per-token heuristic for commands like MODE/NICK/PING where the last argument is structurally an identifier.## [0.8.0] - 2026-04-27### Addedpackage.sglmodified
(package name: "sigil-irc" version: "0.10.0" version: "0.10.1" sigil: "^0.14" description: "IRC protocol library — both directions, IRCv3 + drafts" url: "https://codeberg.org/sigil/sigil-irc"src/sigil/irc/connection.sglmodified
;; Commands ;; ============================================================ ;;; Send a PRIVMSG to a channel or user. ;;; Send a PRIVMSG to a channel or user. The body is emitted as a ;;; `:trailing` parameter unconditionally — even for short ;;; spaceless bodies like `connected.` — so strict receivers ;;; populate `irc-message-trailing` instead of tucking the body ;;; into a middle param and leaving trailing #f. (define (irc-privmsg conn target text) (: irc-connection? string? string? -> void?) (irc-send-raw conn (irc-command->string "PRIVMSG" target text))) (irc-send-raw conn (string-append (make-trailing-command "PRIVMSG" target text) "\r\n"))) ;;; Send a NOTICE to a channel or user. ;;; Send a NOTICE to a channel or user. Same trailing-colon rule ;;; as `irc-privmsg`. (define (irc-notice conn target text) (: irc-connection? string? string? -> void?) (irc-send-raw conn (irc-command->string "NOTICE" target text))) (irc-send-raw conn (string-append (make-trailing-command "NOTICE" target text) "\r\n"))) ;;; Join a channel. (define (irc-join conn channel . key)src/sigil/irc/message.sglmodified
;; Construction make-irc-command make-trailing-command make-irc-command/tags irc-command->string) ;;; Create an IRC command string (no tags, no prefix) from parts. ;;; ;;; The last argument gets a `:` prefix when it's text-shaped — i.e. ;;; contains a space, starts with `:`, or is empty — to disambiguate ;;; the trailing parameter from the middle params. For unambiguous ;;; single-token last params (like a nick or channel name) the `:` ;;; is omitted, matching the canonical IRCv2 wire (e.g. `MODE #c +o ;;; alice`, not `MODE #c +o :alice`). ;;; ;;; Note: this leaves a hazard for messages whose semantic role is ;;; "trailing text" but whose value happens to be a single ;;; spaceless token (e.g. `PRIVMSG #c connected.`). Some parsers ;;; (the strict ones) treat that as a middle param and leave the ;;; trailing slot empty — losing the message body. For those use ;;; cases, prefer `make-trailing-command` (or `irc-privmsg`/ ;;; `irc-notice`, which always force `:`) over `make-irc-command`. ;;; ;;; ```scheme ;;; (make-irc-command "PRIVMSG" "#channel" "Hello world") ;;; ; => "PRIVMSG #channel :Hello world" ;;; ;;; (make-irc-command "MODE" "#channel" "+o" "alice") ;;; ; => "MODE #channel +o alice" ;;; ``` (define (make-irc-command command . args) (: string? string? ... -> string?) rest)) (loop (cons arg parts) rest))))))) ;;; Build a wire string where the LAST argument is unconditionally ;;; emitted with a `:` prefix. Use this for commands whose final ;;; parameter is human/agent text (PRIVMSG, NOTICE, TOPIC, QUIT, ;;; PART message, KICK reason). The unconditional `:` ensures ;;; strict parsers always populate `irc-message-trailing` rather ;;; than tucking a single-token body into `params`. ;;; ;;; ```scheme ;;; (make-trailing-command "PRIVMSG" "#chan" "hi") ;;; ; => "PRIVMSG #chan :hi" ;;; ;;; (make-trailing-command "PRIVMSG" "#chan" "hello world") ;;; ; => "PRIVMSG #chan :hello world" ;;; ``` (define (make-trailing-command command . args) (: string? string? ... -> string?) (if (null? args) command (let loop ((parts (list command)) (remaining args)) (if (null? remaining) (string-join (reverse parts) " ") (let ((arg (car remaining)) (rest (cdr remaining))) (if (null? rest) (loop (cons (string-append ":" arg) parts) rest) (loop (cons arg parts) rest))))))) ;;; Build a tagged IRC command line (no CRLF). Pass `tags` as an alist ;;; of (key . value-or-#f). Keys with value `#f` or `'flag` serialize ;;; as valueless. Pass `prefix:` to include a sender prefix.test/test-message.sglmodified
(make-irc-command/tags command: "PING" params: '("foo"))))) ;; ============================================================ ;; make-trailing-command — unconditional `:` on the last param, ;; for commands whose final argument is the message body ;; (PRIVMSG, NOTICE, TOPIC, etc.). Without the colon, strict ;; parsers route a short single-token body into the middle-params ;; slot and leave trailing #f, dropping the message. ;; ============================================================ (test-group "make-trailing-command" (test "single-token body still gets the colon" ;; The bug: make-irc-command would produce "PRIVMSG #c connected." ;; (no colon) since the body has no spaces. After a round-trip ;; through a re-encoding server (which uses make-irc-command ;; via params: shape), the receiver's parser sees no trailing ;; and the body lands in params, leaving irc-message-trailing #f. (assert-equal "PRIVMSG #channel :connected." (make-trailing-command "PRIVMSG" "#channel" "connected."))) (test "multi-word body keeps the colon (same as before)" (assert-equal "PRIVMSG #channel :hello world" (make-trailing-command "PRIVMSG" "#channel" "hello world"))) (test "empty body emits :" (assert-equal "NOTICE alice :" (make-trailing-command "NOTICE" "alice" ""))) (test "round-trips through parser into trailing" ;; The whole point: parse-irc-message must populate ;; irc-message-trailing for a make-trailing-command output. (let ((msg (parse-irc-message (make-trailing-command "PRIVMSG" "#hive" "connected.")))) (assert-true msg) (assert-equal 'PRIVMSG (irc-message-command msg)) (assert-equal "#hive" (irc-message-target msg)) (assert-equal "connected." (irc-message-trailing msg)) (assert-equal "connected." (irc-message-text msg)))) (test "make-irc-command's own behavior unchanged for spaceless" ;; Sanity: we deliberately did NOT change make-irc-command. ;; MODE, NICK, etc. still emit unambiguous single-token last ;; params without a colon. (assert-equal "MODE #channel +o alice" (make-irc-command "MODE" "#channel" "+o" "alice")) (assert-equal "NICK mynick" (make-irc-command "NICK" "mynick"))) (test "irc-privmsg wire-shape: round-trip via parse-irc-message" ;; Verifies the irc-privmsg path emits a `:trailing` even for ;; short single-token bodies. We can't easily assert against ;; the live wire here without a socket, but since irc-privmsg ;; delegates to make-trailing-command, the round-trip below ;; matches what hits the wire. (let ((wire (string-append (make-trailing-command "PRIVMSG" "#hive" "ack") "\r\n"))) (assert-equal "PRIVMSG #hive :ack\r\n" wire)))) (test-group "irc-command->string adds CRLF" (test "adds CRLF"