Add Tier 2 caps: batch, chathistory, read-marker, MONITOR
(sigil irc batch) — BATCH command + tag helpers: batch-start-line reftag type [params:] [prefix:] batch-end-line reftag [prefix:] make-batch-reftag — server-side reftag generator (URL-safe) batch-context — client-side tracker for open batches batch-tag-of msg — extract batch tag value batch-type-of ctx reftag — look up the type of an open batch
(sigil irc chathistory) — query parsing + serialization for the draft/chathistory capability. All six subcommands: BEFORE, AFTER, LATEST, AROUND, BETWEEN — target + selector(s) + limit TARGETS — two selectors + limit (no target arg)
Selector forms: timestamp=<RFC-3339>, msgid=<id>, *
parse-chathistory-line + chathistory-line round-trip cleanly.
FAIL standard-reply error codes:
INVALID_PARAMS, INVALID_TARGET, MESSAGE_ERROR, NEED_MORE_PARAMS(sigil irc read-marker) — MARKREAD parser + builders for the draft/read-marker capability (cross-device "last-read" sync). Query form: MARKREAD <target> Set form: MARKREAD <target> timestamp=<RFC-3339> Server echoes the set form back to all sessions of the same account so the marker propagates.
(sigil irc monitor) — MONITOR subcommand parsing (+/-, C, L, S) + server-side numeric reply builders: RPL-MONONLINE (730) — online presence with userhost RPL-MONOFFLINE (731) — offline presence (nick only) RPL-MONLIST (732) — list response chunk RPL-ENDOFMONLIST (733) ERR-MONLISTFULL (734) — server's per-user limit hit
src/sigil/irc/batch.sgl | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/irc/chathistory.sgl | 263 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/irc/monitor.sgl | 181 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/irc/read-marker.sgl | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-batch.sgl | 78 ++++++++++++++++++++++++++++++++++++++++++++++++
test/test-chathistory.sgl | 122 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-monitor.sgl | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-read-marker.sgl | 57 +++++++++++++++++++++++++++++++++++
8 files changed, 1057 insertions(+)src/sigil/irc/batch.sgladded
;;; (sigil irc batch) - IRCv3 batch tag and BATCH command helpers;;;;;; The IRCv3 `batch` capability lets a server group related messages;;; into a single batch identified by a reference tag. Clients that;;; negotiate `batch` see:;;;;;; :server BATCH +<reftag> <batch-type> [params...];;; @batch=<reftag> :sender PRIVMSG #chan :line 1;;; @batch=<reftag> :sender PRIVMSG #chan :line 2;;; :server BATCH -<reftag>;;;;;; Reftags are server-chosen short strings unique within an open-batch;;; window on a connection. Common batch types include `chathistory`,;;; `netsplit`, `netjoin`, `multiline`, and the read-marker batch;;; defined by the chathistory spec.;;;;;; This module provides:;;;;;; - The `batch` capability constant;;; - Helpers to build BATCH start/end lines;;; - A `batch-context` record to track open batches when receiving;;; - A reftag generator suitable for server use(define-library (sigil irc batch) (import (sigil core) (sigil string) (sigil struct) (sigil crypto) (sigil irc message)) (export ;; Wire helpers batch-start-line batch-end-line ;; Reftag generation (server-side) make-batch-reftag ;; Reception side: track open batches batch-context batch-context? make-batch-context batch-context-open-tags batch-context-types batch-context-open? batch-open! batch-close! batch-tag-of batch-type-of) (begin ;;; Build a `BATCH +<reftag> <type> [params...]` opening line. ;;; `prefix` is the optional source (typically the server name). ;;; ;;; ```scheme ;;; (batch-start-line "abc123" "chathistory" params: '("#channel")) ;;; ; => "BATCH +abc123 chathistory #channel\r\n" ;;; ``` (define (batch-start-line reftag type (keys: (params '()) (prefix #f))) (: string? string? (params: list?) (prefix: any?) -> string?) (let* ((prefix-part (if prefix (string-append ":" prefix " ") "")) (param-part (if (null? params) "" (string-append " " (string-join params " "))))) (string-append prefix-part "BATCH +" reftag " " type param-part "\r\n"))) ;;; Build a `BATCH -<reftag>` closing line. (define (batch-end-line reftag (keys: (prefix #f))) (: string? (prefix: any?) -> string?) (let ((prefix-part (if prefix (string-append ":" prefix " ") ""))) (string-append prefix-part "BATCH -" reftag "\r\n"))) ;; ============================================================ ;; Reftag generation ;; ============================================================ ;;; Generate a fresh batch reference tag. The tag is opaque to ;;; clients, must be unique among currently-open batches on the ;;; connection, and is conventionally short (10-16 chars). We use ;;; 8 random bytes base64-encoded with the trailing `=` stripped, ;;; yielding 11 URL-safe-ish chars. (define (make-batch-reftag) (: -> string?) (let* ((bytes (random-bytes 8)) (encoded (base64-encode bytes))) ;; Strip any padding `=` and replace `+` / `/` with safe chars. (string-replace (string-replace (string-replace encoded "=" "") "+" "x") "/" "y"))) ;; ============================================================ ;; Reception-side context ;; ============================================================ ;; ;; Clients (and servers receiving messages-in-batches from peers, ;; e.g. for plug-in features) want to know "is this PRIVMSG part of ;; an open batch, and if so, of what type?" The batch context ;; tracks open reftags and their types, populated as BATCH +/- ;; lines arrive. (define-struct batch-context ;; alist of reftag -> #t (just open-set membership) (open-tags default: '() mutable: #t) ;; alist of reftag -> batch-type-string (types default: '() mutable: #t)) (define (make-batch-context) (: -> batch-context?) (batch-context)) (define (batch-context-open? ctx reftag) (: batch-context? string? -> boolean?) (let loop ((xs (batch-context-open-tags ctx))) (cond ((null? xs) #f) ((equal? (caar xs) reftag) #t) (else (loop (cdr xs)))))) ;;; Record a BATCH start. `reftag` is parsed by the consumer (the ;;; leading `+` is stripped). `type` is the batch type string. (define (batch-open! ctx reftag type) (: batch-context? string? string? -> void?) (set-batch-context-open-tags! ctx (cons (cons reftag #t) (batch-context-open-tags ctx))) (set-batch-context-types! ctx (cons (cons reftag type) (batch-context-types ctx)))) ;;; Record a BATCH end. `reftag` should be the bare reftag (no `-`). (define (batch-close! ctx reftag) (: batch-context? string? -> void?) (set-batch-context-open-tags! ctx (filter (lambda (e) (not (equal? (car e) reftag))) (batch-context-open-tags ctx))) (set-batch-context-types! ctx (filter (lambda (e) (not (equal? (car e) reftag))) (batch-context-types ctx)))) ;;; If a message has a `batch` tag, return its reftag string. (define (batch-tag-of msg) (: irc-message? -> any?) (let ((v (irc-message-tag msg "batch"))) (and (string? v) v))) ;;; Look up the batch type associated with a reftag in the context. (define (batch-type-of ctx reftag) (: batch-context? string? -> any?) (let loop ((xs (batch-context-types ctx))) (cond ((null? xs) #f) ((equal? (caar xs) reftag) (cdar xs)) (else (loop (cdr xs)))))) ))src/sigil/irc/chathistory.sgladded
;;; (sigil irc chathistory) - CHATHISTORY command helpers (IRCv3 draft);;;;;; CHATHISTORY lets clients query history without disconnecting. The;;; capability tag `draft/chathistory` advertises a `chathistory=<MAX>`;;; integer for the server's per-query message limit.;;;;;; Subcommands (per https://ircv3.net/specs/extensions/chathistory):;;;;;; CHATHISTORY BEFORE <target> <selector> <limit>;;; CHATHISTORY AFTER <target> <selector> <limit>;;; CHATHISTORY LATEST <target> <selector> <limit>;;; CHATHISTORY AROUND <target> <selector> <limit>;;; CHATHISTORY BETWEEN <target> <selector1> <selector2> <limit>;;; CHATHISTORY TARGETS <selector1> <selector2> <limit>;;;;;; A selector is one of:;;;;;; timestamp=<RFC-3339-UTC>;;; msgid=<msgid-string>;;; * (means "current" / endpoint);;;;;; Server responses are wrapped in a `chathistory` batch tagged with;;; the `target` channel/nick (a single param after the type), and the;;; messages within it carry their original `msgid` and `time` tags so;;; the client can re-anchor its view.;;;;;; Failures use the IRCv3 standard-replies format:;;;;;; FAIL CHATHISTORY <error-code> <subcommand> [<params>...] :<text>;;;;;; with `<error-code>` from a small enum: INVALID_PARAMS, INVALID_TARGET,;;; MESSAGE_ERROR, NEED_MORE_PARAMS.;;;;;; This module provides:;;;;;; - Subcommand and selector enumerated constants;;; - A `chathistory-query` record + parser/serializer for the;;; client→server line;;; - Helpers to build the server's batch+messages response;;; - Helpers to build the FAIL standard-reply(define-library (sigil irc chathistory) (import (sigil core) (sigil string) (sigil struct) (sigil irc message) (sigil irc batch)) (export ;; Capability + batch type CHATHISTORY-BATCH-TYPE ;; Subcommand strings CHATHISTORY-BEFORE CHATHISTORY-AFTER CHATHISTORY-LATEST CHATHISTORY-AROUND CHATHISTORY-BETWEEN CHATHISTORY-TARGETS ;; FAIL error codes CHATHISTORY-FAIL-INVALID-PARAMS CHATHISTORY-FAIL-INVALID-TARGET CHATHISTORY-FAIL-MESSAGE-ERROR CHATHISTORY-FAIL-NEED-MORE-PARAMS ;; Selector parser + record chathistory-selector chathistory-selector? make-chathistory-selector chathistory-selector-kind chathistory-selector-value parse-chathistory-selector chathistory-selector->string ;; Query record chathistory-query chathistory-query? chathistory-query-subcommand chathistory-query-target chathistory-query-selector1 chathistory-query-selector2 chathistory-query-limit parse-chathistory-line chathistory-line) (begin (define CHATHISTORY-BATCH-TYPE "chathistory") (define CHATHISTORY-BEFORE "BEFORE") (define CHATHISTORY-AFTER "AFTER") (define CHATHISTORY-LATEST "LATEST") (define CHATHISTORY-AROUND "AROUND") (define CHATHISTORY-BETWEEN "BETWEEN") (define CHATHISTORY-TARGETS "TARGETS") (define CHATHISTORY-FAIL-INVALID-PARAMS "INVALID_PARAMS") (define CHATHISTORY-FAIL-INVALID-TARGET "INVALID_TARGET") (define CHATHISTORY-FAIL-MESSAGE-ERROR "MESSAGE_ERROR") (define CHATHISTORY-FAIL-NEED-MORE-PARAMS "NEED_MORE_PARAMS") ;; ============================================================ ;; Selector ;; ============================================================ ;;; Selectors identify positions in history. `kind` is one of: ;;; ;;; 'timestamp value is an RFC 3339 UTC timestamp string ;;; 'msgid value is a server-assigned msgid string ;;; 'star value is #f (selector is `*`, meaning "now") ;;; (define-struct chathistory-selector (kind) (value default: #f)) (define (make-chathistory-selector kind value) (: symbol? any? -> chathistory-selector?) (chathistory-selector kind: kind value: value)) ;;; Parse a single selector token. Returns a selector record on ;;; success or `#f` on malformed input. (define (parse-chathistory-selector token) (: string? -> any?) (cond ((string=? token "*") (chathistory-selector kind: 'star value: #f)) ((string-starts-with? token "timestamp=") (chathistory-selector kind: 'timestamp value: (substring token 10 (string-length token)))) ((string-starts-with? token "msgid=") (chathistory-selector kind: 'msgid value: (substring token 6 (string-length token)))) (else #f))) ;;; Serialize a selector back to its wire form. (define (chathistory-selector->string sel) (: chathistory-selector? -> string?) (case (chathistory-selector-kind sel) ((star) "*") ((timestamp) (string-append "timestamp=" (chathistory-selector-value sel))) ((msgid) (string-append "msgid=" (chathistory-selector-value sel))) (else (error "chathistory-selector->string: bad kind")))) ;; ============================================================ ;; Query record + parsing ;; ============================================================ ;;; A parsed CHATHISTORY query. ;;; ;;; subcommand: string, one of CHATHISTORY-* constants ;;; target: string, channel or nick (#f for TARGETS subcommand) ;;; selector1: chathistory-selector ;;; selector2: chathistory-selector or #f (BETWEEN/TARGETS only) ;;; limit: integer (define-struct chathistory-query (subcommand) (target default: #f) (selector1) (selector2 default: #f) (limit default: 100)) ;;; Parse an incoming CHATHISTORY message into a query record. ;;; Returns the query on success, or `#f` if malformed. ;;; ;;; ```scheme ;;; (parse-chathistory-line ;;; (parse-irc-message "CHATHISTORY BEFORE #chan timestamp=2026-04-27T12:00:00Z 50")) ;;; ; => #<chathistory-query subcommand: "BEFORE" target: "#chan" ;;; ; selector1: #<sel timestamp> selector2: #f limit: 50> ;;; ``` (define (parse-chathistory-line msg) (: irc-message? -> any?) (let ((cmd (irc-message-command msg)) (params (irc-message-params msg))) (cond ((not (eq? cmd 'CHATHISTORY)) #f) ((null? params) #f) (else (let ((sub (string-upcase (car params))) (rest (cdr params))) (cond ((string=? sub CHATHISTORY-TARGETS) ;; CHATHISTORY TARGETS <selector1> <selector2> <limit> (and (= (length rest) 3) (let ((s1 (parse-chathistory-selector (car rest))) (s2 (parse-chathistory-selector (cadr rest))) (lim (string->number (caddr rest)))) (and s1 s2 lim (chathistory-query subcommand: sub target: #f selector1: s1 selector2: s2 limit: lim))))) ((string=? sub CHATHISTORY-BETWEEN) ;; BETWEEN <target> <selector1> <selector2> <limit> (and (= (length rest) 4) (let ((tgt (car rest)) (s1 (parse-chathistory-selector (cadr rest))) (s2 (parse-chathistory-selector (caddr rest))) (lim (string->number (cadddr rest)))) (and s1 s2 lim (chathistory-query subcommand: sub target: tgt selector1: s1 selector2: s2 limit: lim))))) ((or (string=? sub CHATHISTORY-BEFORE) (string=? sub CHATHISTORY-AFTER) (string=? sub CHATHISTORY-LATEST) (string=? sub CHATHISTORY-AROUND)) ;; <SUB> <target> <selector> <limit> (and (= (length rest) 3) (let ((tgt (car rest)) (s1 (parse-chathistory-selector (cadr rest))) (lim (string->number (caddr rest)))) (and s1 lim (chathistory-query subcommand: sub target: tgt selector1: s1 limit: lim))))) (else #f))))))) ;;; Serialize a chathistory-query back to a wire-format CHATHISTORY ;;; line (with CRLF). Useful for client-side construction. (define (chathistory-line query) (: chathistory-query? -> string?) (let ((sub (chathistory-query-subcommand query))) (cond ((string=? sub CHATHISTORY-TARGETS) (string-append "CHATHISTORY " sub " " (chathistory-selector->string (chathistory-query-selector1 query)) " " (chathistory-selector->string (chathistory-query-selector2 query)) " " (number->string (chathistory-query-limit query)) "\r\n")) ((string=? sub CHATHISTORY-BETWEEN) (string-append "CHATHISTORY " sub " " (chathistory-query-target query) " " (chathistory-selector->string (chathistory-query-selector1 query)) " " (chathistory-selector->string (chathistory-query-selector2 query)) " " (number->string (chathistory-query-limit query)) "\r\n")) (else (string-append "CHATHISTORY " sub " " (chathistory-query-target query) " " (chathistory-selector->string (chathistory-query-selector1 query)) " " (number->string (chathistory-query-limit query)) "\r\n"))))) ))src/sigil/irc/monitor.sgladded
;;; (sigil irc monitor) - MONITOR command helpers (IRCv3);;;;;; MONITOR provides efficient online/offline presence tracking. Clients;;; subscribe to a list of nicks and receive RPL-MONONLINE / RPL-MONOFFLINE;;; notifications when those nicks change presence. This replaces the;;; expensive WHOIS-polling pattern.;;;;;; Subcommands (per https://ircv3.net/specs/extensions/monitor):;;;;;; MONITOR + <nick>[,<nick>...] add nicks to your monitor list;;; MONITOR - <nick>[,<nick>...] remove nicks;;; MONITOR C clear list;;; MONITOR L list current monitored nicks;;; MONITOR S fetch current online/offline status;;;;;; Server numerics:;;;;;; 730 (RPL-MONONLINE) "<client> :nick!user@host[,...]" they came online;;; 731 (RPL-MONOFFLINE) "<client> :nick[,...]" they went offline;;; 732 (RPL-MONLIST) "<client> :nick[,...]" list response;;; 733 (RPL-ENDOFMONLIST) "<client> :End of MONITOR list";;; 734 (ERR-MONLISTFULL) "<client> <limit> <nick>[,...] :Monitor list is full."(define-library (sigil irc monitor) (import (sigil core) (sigil string) (sigil struct) (sigil irc message) (sigil irc numerics)) (export ;; Subcommand letter constants MONITOR-ADD MONITOR-REMOVE MONITOR-CLEAR MONITOR-LIST MONITOR-STATUS ;; Record + parser monitor-command monitor-command? monitor-command-subcommand monitor-command-targets parse-monitor-line monitor-line ;; Server reply helpers monitor-online-line monitor-offline-line monitor-list-line monitor-end-of-list-line monitor-list-full-line) (begin (define MONITOR-ADD "+") (define MONITOR-REMOVE "-") (define MONITOR-CLEAR "C") (define MONITOR-LIST "L") (define MONITOR-STATUS "S") ;; ============================================================ ;; Parsed-command record ;; ============================================================ (define-struct monitor-command (subcommand) ; "+" / "-" / "C" / "L" / "S" (targets default: '())) ; list of nick strings (empty for C/L/S) ;;; Parse a MONITOR command into a monitor-command record. Returns ;;; `#f` on malformed input. ;;; ;;; ```scheme ;;; (parse-monitor-line (parse-irc-message "MONITOR + alice,bob")) ;;; ; => #<monitor-command subcommand: "+" targets: ("alice" "bob")> ;;; ``` (define (parse-monitor-line msg) (: irc-message? -> any?) (let ((cmd (irc-message-command msg)) (params (irc-message-params msg))) (cond ((not (eq? cmd 'MONITOR)) #f) ((null? params) #f) (else (let ((sub (car params)) (rest (cdr params))) (cond ((or (string=? sub MONITOR-CLEAR) (string=? sub MONITOR-LIST) (string=? sub MONITOR-STATUS)) (monitor-command subcommand: sub)) ((or (string=? sub MONITOR-ADD) (string=? sub MONITOR-REMOVE)) (cond ((null? rest) #f) (else (monitor-command subcommand: sub targets: (string-split (car rest) ","))))) (else #f))))))) ;;; Build a MONITOR client→server line. ;;; ;;; ```scheme ;;; (monitor-line "+" '("alice" "bob")) ; => "MONITOR + alice,bob\r\n" ;;; (monitor-line "C") ; => "MONITOR C\r\n" ;;; ``` (define (monitor-line sub . targets) (: string? string? ... -> string?) (cond ((or (string=? sub MONITOR-CLEAR) (string=? sub MONITOR-LIST) (string=? sub MONITOR-STATUS)) (string-append "MONITOR " sub "\r\n")) ((null? targets) (error "monitor-line: + and - require at least one target")) (else (string-append "MONITOR " sub " " (string-join (apply append (map listify targets)) ",") "\r\n")))) (define (listify x) (cond ((list? x) x) ((string? x) (list x)) (else (list x)))) ;; ============================================================ ;; Server numeric reply builders ;; ============================================================ (define (monitor-numeric-line server-name code client-nick text) (string-append (if server-name (string-append ":" server-name " ") "") code " " (or client-nick "*") " :" text "\r\n")) ;;; Build an RPL-MONONLINE (730) line. `entries` is a list of ;;; `nick!user@host` strings. Multiple targets are comma-joined per ;;; the spec. (define (monitor-online-line client-nick entries (keys: (server-name #f))) (: any? list? (server-name: any?) -> string?) (monitor-numeric-line server-name RPL-MONONLINE client-nick (string-join entries ","))) ;;; Build an RPL-MONOFFLINE (731) line. `nicks` is a list of nick ;;; strings. (define (monitor-offline-line client-nick nicks (keys: (server-name #f))) (: any? list? (server-name: any?) -> string?) (monitor-numeric-line server-name RPL-MONOFFLINE client-nick (string-join nicks ","))) ;;; Build an RPL-MONLIST (732) line. May be sent multiple times if ;;; the list is large. (define (monitor-list-line client-nick nicks (keys: (server-name #f))) (: any? list? (server-name: any?) -> string?) (monitor-numeric-line server-name RPL-MONLIST client-nick (string-join nicks ","))) ;;; Build an RPL-ENDOFMONLIST (733) terminator. (define (monitor-end-of-list-line client-nick (keys: (server-name #f))) (: any? (server-name: any?) -> string?) (monitor-numeric-line server-name RPL-ENDOFMONLIST client-nick "End of MONITOR list")) ;;; Build an ERR-MONLISTFULL (734) error: server's per-user limit ;;; reached. `limit` is the integer limit; `nicks` is the rejected ;;; targets. (define (monitor-list-full-line client-nick limit nicks (keys: (server-name #f))) (: any? integer? list? (server-name: any?) -> string?) (let ((prefix (if server-name (string-append ":" server-name " ") ""))) (string-append prefix ERR-MONLISTFULL " " (or client-nick "*") " " (number->string limit) " " (string-join nicks ",") " :Monitor list is full.\r\n"))) ))src/sigil/irc/read-marker.sgladded
;;; (sigil irc read-marker) - draft/read-marker capability helpers;;;;;; The `draft/read-marker` capability gives clients a way to sync the;;; "last-read" position of a target (channel or nick) across devices.;;; The wire surface is two messages:;;;;;; C: MARKREAD <target> [timestamp=<RFC-3339-UTC>];;; S: MARKREAD <target> timestamp=<RFC-3339-UTC>;;;;;; A query (no timestamp) asks the server for the current value. A;;; set (with timestamp) updates the server's stored value, and the;;; server echoes the new state back to all sessions of the same;;; account so other devices learn the new marker.;;;;;; Spec: https://ircv3.net/specs/extensions/read-marker(define-library (sigil irc read-marker) (import (sigil core) (sigil string) (sigil struct) (sigil irc message)) (export ;; Capability constant (reused from (sigil irc capability) but ;; exported here for convenience when this is the only feature ;; the consumer cares about) READ-MARKER-CAP ;; Record + parser read-marker read-marker? make-read-marker read-marker-target read-marker-timestamp parse-markread-line ;; Wire builders markread-query-line markread-set-line) (begin (define READ-MARKER-CAP "draft/read-marker") ;;; A single read-marker datum: target string + optional timestamp. ;;; A query has timestamp `#f`; an updated/echoed marker carries ;;; an RFC 3339 UTC timestamp. (define-struct read-marker (target) (timestamp default: #f)) (define (make-read-marker target (keys: (timestamp #f))) (: string? (timestamp: any?) -> read-marker?) (read-marker target: target timestamp: timestamp)) ;;; Parse a `MARKREAD <target> [timestamp=...]` line. Accepts both ;;; the client→server form (timestamp optional) and the ;;; server→client form (timestamp present). Returns a read-marker ;;; record on success, `#f` on malformed input. (define (parse-markread-line msg) (: irc-message? -> any?) (let ((cmd (irc-message-command msg)) (params (irc-message-params msg))) (cond ((not (eq? cmd 'MARKREAD)) #f) ((null? params) #f) ((= (length params) 1) (read-marker target: (car params))) ((= (length params) 2) (let ((target (car params)) (ts-token (cadr params))) (cond ((string-starts-with? ts-token "timestamp=") (read-marker target: target timestamp: (substring ts-token 10 (string-length ts-token)))) (else #f)))) (else #f)))) ;;; Build a query line: client asking the server for the current ;;; marker for `target`. ;;; ;;; ```scheme ;;; (markread-query-line "#channel") ;;; ; => "MARKREAD #channel\r\n" ;;; ``` (define (markread-query-line target) (: string? -> string?) (string-append "MARKREAD " target "\r\n")) ;;; Build a set/echo line: client setting (or server echoing) a ;;; marker. `timestamp` is an RFC 3339 UTC string. ;;; ;;; ```scheme ;;; (markread-set-line "#channel" "2026-04-27T12:00:00.000Z") ;;; ; => "MARKREAD #channel timestamp=2026-04-27T12:00:00.000Z\r\n" ;;; ``` (define (markread-set-line target timestamp (keys: (prefix #f))) (: string? string? (prefix: any?) -> string?) (let ((prefix-part (if prefix (string-append ":" prefix " ") ""))) (string-append prefix-part "MARKREAD " target " timestamp=" timestamp "\r\n"))) ))test/test-batch.sgladded
;;; Tests for BATCH command and batch context tracking.(import (sigil test) (sigil irc message) (sigil irc batch))(test-group "BATCH wire helpers" (test "batch-start-line minimal" (assert-equal "BATCH +abc chathistory\r\n" (batch-start-line "abc" "chathistory"))) (test "batch-start-line with params" (assert-equal "BATCH +abc chathistory #channel\r\n" (batch-start-line "abc" "chathistory" params: '("#channel")))) (test "batch-start-line with prefix" (assert-equal ":server BATCH +abc chathistory\r\n" (batch-start-line "abc" "chathistory" prefix: "server"))) (test "batch-end-line" (assert-equal "BATCH -abc\r\n" (batch-end-line "abc"))) (test "batch-end-line with prefix" (assert-equal ":server BATCH -abc\r\n" (batch-end-line "abc" prefix: "server"))))(test-group "Reftag generation" (test "make-batch-reftag yields a non-empty string" (let ((tag (make-batch-reftag))) (assert-true (string? tag)) (assert-true (> (string-length tag) 0)))) (test "two reftags differ" (assert-false (string=? (make-batch-reftag) (make-batch-reftag)))) (test "reftag avoids `=`, `+`, `/`" (let ((tag (make-batch-reftag))) (assert-false (string-contains? tag "=")) (assert-false (string-contains? tag "+")) (assert-false (string-contains? tag "/")))))(test-group "Batch context tracking" (test "open + close lifecycle" (let ((ctx (make-batch-context))) (assert-false (batch-context-open? ctx "abc")) (batch-open! ctx "abc" "chathistory") (assert-true (batch-context-open? ctx "abc")) (assert-equal "chathistory" (batch-type-of ctx "abc")) (batch-close! ctx "abc") (assert-false (batch-context-open? ctx "abc")) (assert-equal #f (batch-type-of ctx "abc")))) (test "multiple open batches" (let ((ctx (make-batch-context))) (batch-open! ctx "a" "chathistory") (batch-open! ctx "b" "netjoin") (assert-true (batch-context-open? ctx "a")) (assert-true (batch-context-open? ctx "b")) (assert-equal "chathistory" (batch-type-of ctx "a")) (assert-equal "netjoin" (batch-type-of ctx "b")))))(test-group "batch-tag-of" (test "extracts batch tag from message" (let ((msg (parse-irc-message "@batch=abc :n!u@h PRIVMSG #c :hi"))) (assert-equal "abc" (batch-tag-of msg)))) (test "returns #f when batch tag absent" (let ((msg (parse-irc-message ":n!u@h PRIVMSG #c :hi"))) (assert-equal #f (batch-tag-of msg)))))(run-tests)test/test-chathistory.sgladded
;;; Tests for CHATHISTORY query parsing/serialization.(import (sigil test) (sigil irc message) (sigil irc chathistory))(test-group "Chathistory selector parsing" (test "* selector" (let ((s (parse-chathistory-selector "*"))) (assert-equal 'star (chathistory-selector-kind s)) (assert-equal #f (chathistory-selector-value s)))) (test "timestamp= selector" (let ((s (parse-chathistory-selector "timestamp=2026-04-27T12:00:00Z"))) (assert-equal 'timestamp (chathistory-selector-kind s)) (assert-equal "2026-04-27T12:00:00Z" (chathistory-selector-value s)))) (test "msgid= selector" (let ((s (parse-chathistory-selector "msgid=abc-123"))) (assert-equal 'msgid (chathistory-selector-kind s)) (assert-equal "abc-123" (chathistory-selector-value s)))) (test "rejects unknown form" (assert-equal #f (parse-chathistory-selector "garbage"))))(test-group "Chathistory selector serialization" (test "* round-trip" (assert-equal "*" (chathistory-selector->string (parse-chathistory-selector "*")))) (test "timestamp round-trip" (let ((token "timestamp=2026-04-27T12:00:00Z")) (assert-equal token (chathistory-selector->string (parse-chathistory-selector token))))) (test "msgid round-trip" (let ((token "msgid=abc-123")) (assert-equal token (chathistory-selector->string (parse-chathistory-selector token))))))(test-group "parse-chathistory-line" (test "BEFORE query" (let* ((msg (parse-irc-message "CHATHISTORY BEFORE #channel timestamp=2026-04-27T12:00:00Z 50")) (q (parse-chathistory-line msg))) (assert-true q) (assert-equal "BEFORE" (chathistory-query-subcommand q)) (assert-equal "#channel" (chathistory-query-target q)) (assert-equal 'timestamp (chathistory-selector-kind (chathistory-query-selector1 q))) (assert-equal 50 (chathistory-query-limit q)))) (test "LATEST with msgid" (let* ((msg (parse-irc-message "CHATHISTORY LATEST #foo msgid=abc 100")) (q (parse-chathistory-line msg))) (assert-true q) (assert-equal "LATEST" (chathistory-query-subcommand q)) (assert-equal 'msgid (chathistory-selector-kind (chathistory-query-selector1 q))) (assert-equal "abc" (chathistory-selector-value (chathistory-query-selector1 q))))) (test "BETWEEN with two selectors" (let* ((msg (parse-irc-message "CHATHISTORY BETWEEN #foo timestamp=2026-04-27T00:00:00Z timestamp=2026-04-27T23:59:59Z 200")) (q (parse-chathistory-line msg))) (assert-true q) (assert-equal "BETWEEN" (chathistory-query-subcommand q)) (assert-equal "#foo" (chathistory-query-target q)) (assert-true (chathistory-query-selector2 q)) (assert-equal 200 (chathistory-query-limit q)))) (test "TARGETS query" (let* ((msg (parse-irc-message "CHATHISTORY TARGETS timestamp=2026-04-27T00:00:00Z timestamp=2026-04-28T00:00:00Z 50")) (q (parse-chathistory-line msg))) (assert-true q) (assert-equal "TARGETS" (chathistory-query-subcommand q)) (assert-equal #f (chathistory-query-target q)) (assert-equal 50 (chathistory-query-limit q)))) (test "rejects malformed (missing limit)" (let* ((msg (parse-irc-message "CHATHISTORY BEFORE #foo *")) (q (parse-chathistory-line msg))) (assert-equal #f q))) (test "rejects unknown subcommand" (let* ((msg (parse-irc-message "CHATHISTORY SIDEWAYS #foo * 10")) (q (parse-chathistory-line msg))) (assert-equal #f q))))(test-group "chathistory-line serialization" (test "BEFORE round-trips" (let* ((msg (parse-irc-message "CHATHISTORY BEFORE #channel timestamp=2026-04-27T12:00:00Z 50")) (q (parse-chathistory-line msg)) (line (chathistory-line q))) (assert-equal "CHATHISTORY BEFORE #channel timestamp=2026-04-27T12:00:00Z 50\r\n" line))) (test "BETWEEN round-trips" (let* ((msg (parse-irc-message "CHATHISTORY BETWEEN #foo * msgid=xyz 25")) (q (parse-chathistory-line msg)) (line (chathistory-line q))) (assert-equal "CHATHISTORY BETWEEN #foo * msgid=xyz 25\r\n" line))))(run-tests)test/test-monitor.sgladded
;;; Tests for MONITOR command parsing + numeric reply builders.(import (sigil test) (sigil irc message) (sigil irc monitor) (sigil irc numerics))(test-group "MONITOR parsing" (test "MONITOR + with single nick" (let* ((msg (parse-irc-message "MONITOR + alice")) (mc (parse-monitor-line msg))) (assert-true mc) (assert-equal "+" (monitor-command-subcommand mc)) (assert-equal '("alice") (monitor-command-targets mc)))) (test "MONITOR + with comma list" (let* ((msg (parse-irc-message "MONITOR + alice,bob,carol")) (mc (parse-monitor-line msg))) (assert-true mc) (assert-equal '("alice" "bob" "carol") (monitor-command-targets mc)))) (test "MONITOR -" (let* ((msg (parse-irc-message "MONITOR - alice")) (mc (parse-monitor-line msg))) (assert-true mc) (assert-equal "-" (monitor-command-subcommand mc)))) (test "MONITOR C / L / S have empty targets" (let ((msg-c (parse-irc-message "MONITOR C")) (msg-l (parse-irc-message "MONITOR L")) (msg-s (parse-irc-message "MONITOR S"))) (assert-equal '() (monitor-command-targets (parse-monitor-line msg-c))) (assert-equal '() (monitor-command-targets (parse-monitor-line msg-l))) (assert-equal '() (monitor-command-targets (parse-monitor-line msg-s))))) (test "rejects MONITOR + without targets" (let* ((msg (parse-irc-message "MONITOR +")) (mc (parse-monitor-line msg))) (assert-equal #f mc))) (test "rejects unknown subcommand" (let* ((msg (parse-irc-message "MONITOR Q alice")) (mc (parse-monitor-line msg))) (assert-equal #f mc))))(test-group "MONITOR client line builder" (test "+ list" (assert-equal "MONITOR + alice,bob\r\n" (monitor-line "+" '("alice" "bob")))) (test "C clear" (assert-equal "MONITOR C\r\n" (monitor-line "C"))) (test "L list query" (assert-equal "MONITOR L\r\n" (monitor-line "L"))))(test-group "MONITOR server numeric reply builders" (test "RPL-MONONLINE (730)" (let ((line (monitor-online-line "alice" '("bob!b@host" "carol!c@host")))) (assert-true (string-contains? line RPL-MONONLINE)) (assert-true (string-contains? line "alice")) (assert-true (string-contains? line "bob!b@host,carol!c@host")))) (test "RPL-MONOFFLINE (731)" (let ((line (monitor-offline-line "alice" '("bob")))) (assert-true (string-contains? line RPL-MONOFFLINE)) (assert-true (string-contains? line "bob")))) (test "RPL-ENDOFMONLIST (733)" (let ((line (monitor-end-of-list-line "alice"))) (assert-true (string-contains? line RPL-ENDOFMONLIST)) (assert-true (string-contains? line "End of MONITOR list")))) (test "ERR-MONLISTFULL (734) includes limit and rejected nicks" (let ((line (monitor-list-full-line "alice" 100 '("dan")))) (assert-true (string-contains? line ERR-MONLISTFULL)) (assert-true (string-contains? line "100")) (assert-true (string-contains? line "dan")))))(run-tests)test/test-read-marker.sgladded
;;; Tests for draft/read-marker MARKREAD helpers.(import (sigil test) (sigil irc message) (sigil irc read-marker))(test-group "MARKREAD parsing" (test "query (target only)" (let* ((msg (parse-irc-message "MARKREAD #channel")) (m (parse-markread-line msg))) (assert-true m) (assert-equal "#channel" (read-marker-target m)) (assert-equal #f (read-marker-timestamp m)))) (test "set (with timestamp)" (let* ((msg (parse-irc-message "MARKREAD #channel timestamp=2026-04-27T12:00:00.000Z")) (m (parse-markread-line msg))) (assert-true m) (assert-equal "#channel" (read-marker-target m)) (assert-equal "2026-04-27T12:00:00.000Z" (read-marker-timestamp m)))) (test "echoed from server with prefix" (let* ((msg (parse-irc-message ":server MARKREAD #channel timestamp=2026-04-27T12:00:00.000Z")) (m (parse-markread-line msg))) (assert-true m) (assert-equal "2026-04-27T12:00:00.000Z" (read-marker-timestamp m)))) (test "rejects malformed timestamp token" (let* ((msg (parse-irc-message "MARKREAD #channel garbage")) (m (parse-markread-line msg))) (assert-equal #f m))) (test "rejects empty params" (let* ((msg (parse-irc-message "MARKREAD")) (m (parse-markread-line msg))) (assert-equal #f m))))(test-group "MARKREAD line builders" (test "query line" (assert-equal "MARKREAD #channel\r\n" (markread-query-line "#channel"))) (test "set line without prefix" (assert-equal "MARKREAD #channel timestamp=2026-04-27T12:00:00Z\r\n" (markread-set-line "#channel" "2026-04-27T12:00:00Z"))) (test "set line with prefix (server echo)" (assert-equal ":server MARKREAD #channel timestamp=2026-04-27T12:00:00Z\r\n" (markread-set-line "#channel" "2026-04-27T12:00:00Z" prefix: "server"))))(run-tests)