Commite43183c2Recorded29 Apr 2026Repositorysigil-irc

Fix irc-privmsg/irc-notice: force :trailing on the last param

Message

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.

Changed
 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(-)
Diff
CHANGELOG.mdmodified
@@ -7,6 +7,16 @@ All notable changes to this project will be documented in this file.
7
The format is based on [Keep a Changelog](https://keepachangelog.com/),
8
and this project adheres to [Semantic Versioning](https://semver.org/).
9
+10
## [0.10.1] - 2026-04-29
+11
+12
### Fixed
+13
+14
- `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.
+15
+16
### Added
+17
+18
- `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.
+19
20
## [0.8.0] - 2026-04-27
21
22
### Added
package.sglmodified
@@ -8,7 +8,7 @@
8
9
(package
10
name: "sigil-irc"
11
version: "0.10.0"
+11
version: "0.10.1"
12
sigil: "^0.14"
13
description: "IRC protocol library — both directions, IRCv3 + drafts"
14
url: "https://codeberg.org/sigil/sigil-irc"
src/sigil/irc/connection.sglmodified
@@ -475,15 +475,24 @@
475
;; Commands
476
;; ============================================================
477
478
;;; Send a PRIVMSG to a channel or user.
+478
;;; Send a PRIVMSG to a channel or user. The body is emitted as a
+479
;;; `:trailing` parameter unconditionally — even for short
+480
;;; spaceless bodies like `connected.` — so strict receivers
+481
;;; populate `irc-message-trailing` instead of tucking the body
+482
;;; into a middle param and leaving trailing #f.
483
(define (irc-privmsg conn target text)
484
(: irc-connection? string? string? -> void?)
481
(irc-send-raw conn (irc-command->string "PRIVMSG" target text)))
+485
(irc-send-raw conn
+486
(string-append (make-trailing-command "PRIVMSG" target text)
+487
"\r\n")))
488
483
;;; Send a NOTICE to a channel or user.
+489
;;; Send a NOTICE to a channel or user. Same trailing-colon rule
+490
;;; as `irc-privmsg`.
491
(define (irc-notice conn target text)
492
(: irc-connection? string? string? -> void?)
486
(irc-send-raw conn (irc-command->string "NOTICE" target text)))
+493
(irc-send-raw conn
+494
(string-append (make-trailing-command "NOTICE" target text)
+495
"\r\n")))
496
497
;;; Join a channel.
498
(define (irc-join conn channel . key)
src/sigil/irc/message.sglmodified
@@ -57,6 +57,7 @@
57
58
;; Construction
59
make-irc-command
+60
make-trailing-command
61
make-irc-command/tags
62
irc-command->string)
63
@@ -345,9 +346,27 @@
346
347
;;; Create an IRC command string (no tags, no prefix) from parts.
348
;;;
+349
;;; The last argument gets a `:` prefix when it's text-shaped — i.e.
+350
;;; contains a space, starts with `:`, or is empty — to disambiguate
+351
;;; the trailing parameter from the middle params. For unambiguous
+352
;;; single-token last params (like a nick or channel name) the `:`
+353
;;; is omitted, matching the canonical IRCv2 wire (e.g. `MODE #c +o
+354
;;; alice`, not `MODE #c +o :alice`).
+355
;;;
+356
;;; Note: this leaves a hazard for messages whose semantic role is
+357
;;; "trailing text" but whose value happens to be a single
+358
;;; spaceless token (e.g. `PRIVMSG #c connected.`). Some parsers
+359
;;; (the strict ones) treat that as a middle param and leave the
+360
;;; trailing slot empty — losing the message body. For those use
+361
;;; cases, prefer `make-trailing-command` (or `irc-privmsg`/
+362
;;; `irc-notice`, which always force `:`) over `make-irc-command`.
+363
;;;
364
;;; ```scheme
365
;;; (make-irc-command "PRIVMSG" "#channel" "Hello world")
366
;;; ; => "PRIVMSG #channel :Hello world"
+367
;;;
+368
;;; (make-irc-command "MODE" "#channel" "+o" "alice")
+369
;;; ; => "MODE #channel +o alice"
370
;;; ```
371
(define (make-irc-command command . args)
372
(: string? string? ... -> string?)
@@ -371,6 +390,35 @@
390
rest))
391
(loop (cons arg parts) rest)))))))
392
+393
;;; Build a wire string where the LAST argument is unconditionally
+394
;;; emitted with a `:` prefix. Use this for commands whose final
+395
;;; parameter is human/agent text (PRIVMSG, NOTICE, TOPIC, QUIT,
+396
;;; PART message, KICK reason). The unconditional `:` ensures
+397
;;; strict parsers always populate `irc-message-trailing` rather
+398
;;; than tucking a single-token body into `params`.
+399
;;;
+400
;;; ```scheme
+401
;;; (make-trailing-command "PRIVMSG" "#chan" "hi")
+402
;;; ; => "PRIVMSG #chan :hi"
+403
;;;
+404
;;; (make-trailing-command "PRIVMSG" "#chan" "hello world")
+405
;;; ; => "PRIVMSG #chan :hello world"
+406
;;; ```
+407
(define (make-trailing-command command . args)
+408
(: string? string? ... -> string?)
+409
(if (null? args)
+410
command
+411
(let loop ((parts (list command))
+412
(remaining args))
+413
(if (null? remaining)
+414
(string-join (reverse parts) " ")
+415
(let ((arg (car remaining))
+416
(rest (cdr remaining)))
+417
(if (null? rest)
+418
(loop (cons (string-append ":" arg) parts)
+419
rest)
+420
(loop (cons arg parts) rest)))))))
+421
422
;;; Build a tagged IRC command line (no CRLF). Pass `tags` as an alist
423
;;; of (key . value-or-#f). Keys with value `#f` or `'flag` serialize
424
;;; as valueless. Pass `prefix:` to include a sender prefix.
test/test-message.sglmodified
@@ -192,6 +192,65 @@
192
(make-irc-command/tags command: "PING" params: '("foo")))))
193
194
+195
;; ============================================================
+196
;; make-trailing-command — unconditional `:` on the last param,
+197
;; for commands whose final argument is the message body
+198
;; (PRIVMSG, NOTICE, TOPIC, etc.). Without the colon, strict
+199
;; parsers route a short single-token body into the middle-params
+200
;; slot and leave trailing #f, dropping the message.
+201
;; ============================================================
+202
+203
(test-group "make-trailing-command"
+204
+205
(test "single-token body still gets the colon"
+206
;; The bug: make-irc-command would produce "PRIVMSG #c connected."
+207
;; (no colon) since the body has no spaces. After a round-trip
+208
;; through a re-encoding server (which uses make-irc-command
+209
;; via params: shape), the receiver's parser sees no trailing
+210
;; and the body lands in params, leaving irc-message-trailing #f.
+211
(assert-equal "PRIVMSG #channel :connected."
+212
(make-trailing-command "PRIVMSG" "#channel" "connected.")))
+213
+214
(test "multi-word body keeps the colon (same as before)"
+215
(assert-equal "PRIVMSG #channel :hello world"
+216
(make-trailing-command "PRIVMSG" "#channel" "hello world")))
+217
+218
(test "empty body emits :"
+219
(assert-equal "NOTICE alice :"
+220
(make-trailing-command "NOTICE" "alice" "")))
+221
+222
(test "round-trips through parser into trailing"
+223
;; The whole point: parse-irc-message must populate
+224
;; irc-message-trailing for a make-trailing-command output.
+225
(let ((msg (parse-irc-message
+226
(make-trailing-command "PRIVMSG" "#hive" "connected."))))
+227
(assert-true msg)
+228
(assert-equal 'PRIVMSG (irc-message-command msg))
+229
(assert-equal "#hive" (irc-message-target msg))
+230
(assert-equal "connected." (irc-message-trailing msg))
+231
(assert-equal "connected." (irc-message-text msg))))
+232
+233
(test "make-irc-command's own behavior unchanged for spaceless"
+234
;; Sanity: we deliberately did NOT change make-irc-command.
+235
;; MODE, NICK, etc. still emit unambiguous single-token last
+236
;; params without a colon.
+237
(assert-equal "MODE #channel +o alice"
+238
(make-irc-command "MODE" "#channel" "+o" "alice"))
+239
(assert-equal "NICK mynick"
+240
(make-irc-command "NICK" "mynick")))
+241
+242
(test "irc-privmsg wire-shape: round-trip via parse-irc-message"
+243
;; Verifies the irc-privmsg path emits a `:trailing` even for
+244
;; short single-token bodies. We can't easily assert against
+245
;; the live wire here without a socket, but since irc-privmsg
+246
;; delegates to make-trailing-command, the round-trip below
+247
;; matches what hits the wire.
+248
(let ((wire (string-append
+249
(make-trailing-command "PRIVMSG" "#hive" "ack")
+250
"\r\n")))
+251
(assert-equal "PRIVMSG #hive :ack\r\n" wire))))
+252
+253
254
(test-group "irc-command->string adds CRLF"
255
256
(test "adds CRLF"