AtlatestRepositorysigil-irc
1
;;; (sigil irc websocket) - IRC-over-WebSocket client/session helper2
;;;3
;;; This module composes the public `(sigil websocket)` client API with the4
;;; protocol-only `(sigil irc message)` layer. IRCv3 WebSocket transports carry5
;;; one IRC line per frame without CRLF. This module intentionally does not6
;;; import the native IRC connection, socket, TLS, crypto, or SASL modules, so7
;;; browser WASM builds can use it as a small Enclave-facing transport adapter.9
(define-library (sigil irc websocket)10
(import (sigil core)11
(sigil math)12
(sigil string)13
(sigil struct)14
(sigil websocket)15
(sigil irc message))17
(export18
irc-ws-session19
make-irc-ws-session20
irc-ws-session?21
irc-ws-session-url22
irc-ws-session-nick23
irc-ws-session-user24
irc-ws-session-realname25
irc-ws-session-websocket26
irc-ws-session-state27
irc-ws-session-server-caps28
irc-ws-session-enabled-caps29
irc-ws-session-sasl-result31
irc-ws-connect32
irc-ws-close33
irc-ws-connected?34
irc-ws-register35
irc-ws-negotiate-caps36
irc-ws-authenticate-plain37
irc-ws-register-with-caps38
irc-ws-cap-ls39
irc-ws-cap-req-from-ls40
irc-ws-cap-ack41
irc-ws-cap-end42
irc-ws-authenticate-plain-start43
irc-ws-authenticate-plain-payload44
irc-ws-authenticate-finish46
irc-ws-send47
irc-ws-command48
irc-ws-privmsg49
irc-ws-notice50
irc-ws-join51
irc-ws-part52
irc-ws-quit54
irc-ws-receive)56
(begin58
(define-struct irc-ws-session59
(url)60
(nick default: #f)61
(user default: #f)62
(realname default: "")63
(websocket default: #f mutable: #t)64
(state default: 'disconnected mutable: #t)65
(buffer default: "" mutable: #t)66
(queue default: '() mutable: #t)67
(server-caps default: '() mutable: #t)68
(enabled-caps default: '() mutable: #t)69
(sasl-result default: #f mutable: #t))71
(define (make-irc-ws-session (keys: (url #f)72
(nick #f)73
(user #f)74
(realname "")))75
(: (url: any?) (nick: any?) (user: any?) (realname: string?) -> irc-ws-session?)76
(unless url77
(error "make-irc-ws-session: url: is required"))78
(irc-ws-session79
url: url80
nick: nick81
user: (or user nick)82
realname: (if (and nick (string=? realname "")) nick realname)))84
(define (strip-line-ending line)85
(let ((len (string-length line)))86
(cond87
((and (> len 0)88
(char=? (string-ref line (- len 1)) #\newline))89
(let ((end (- len 1)))90
(if (and (> end 0)91
(char=? (string-ref line (- end 1)) #\return))92
(substring line 0 (- end 1))93
(substring line 0 end))))94
(else line))))96
(define (contains-line-ending? text)97
(if (string-index text (lambda (c) (char=? c #\newline)))98
#t99
#f))101
(define (enqueue-lines! session text)102
(let ((combined (string-append (irc-ws-session-buffer session) text)))103
(let loop ((buffer combined)104
(lines '()))105
(let ((newline-pos (string-index buffer (lambda (c) (char=? c #\newline)))))106
(if newline-pos107
(let ((line (substring buffer 0 (+ newline-pos 1)))108
(rest (substring buffer (+ newline-pos 1) (string-length buffer))))109
(loop rest (cons (strip-line-ending line) lines)))110
(begin111
(set-irc-ws-session-buffer! session buffer)112
(when (pair? lines)113
(set-irc-ws-session-queue!114
session115
(append (irc-ws-session-queue session)116
(reverse lines))))))))))118
(define (enqueue-message! session text)119
(if (or (contains-line-ending? text)120
(not (string=? (irc-ws-session-buffer session) "")))121
(enqueue-lines! session text)122
(set-irc-ws-session-queue!123
session124
(append (irc-ws-session-queue session)125
(list text)))))127
(define (pop-message! session)128
(let ((queue (irc-ws-session-queue session)))129
(if (null? queue)130
#f131
(let ((line (car queue)))132
(set-irc-ws-session-queue! session (cdr queue))133
(parse-irc-message line)))))135
(define base64-alphabet136
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")138
(define (base64-char n)139
(string (string-ref base64-alphabet n)))141
(define (base64-encode-string input)142
(let ((len (string-length input)))143
(let loop ((i 0) (acc '()))144
(if (>= i len)145
(apply string-append (reverse acc))146
(let* ((remaining (- len i))147
(b1 (char->integer (string-ref input i)))148
(b2 (if (> remaining 1)149
(char->integer (string-ref input (+ i 1)))150
0))151
(b3 (if (> remaining 2)152
(char->integer (string-ref input (+ i 2)))153
0))154
(n (+ (* b1 65536) (* b2 256) b3))155
(s1 (quotient n 262144))156
(s2 (remainder (quotient n 4096) 64))157
(s3 (remainder (quotient n 64) 64))158
(s4 (remainder n 64))159
(chunk160
(cond161
((= remaining 1)162
(string-append (base64-char s1)163
(base64-char s2)164
"=="))165
((= remaining 2)166
(string-append (base64-char s1)167
(base64-char s2)168
(base64-char s3)169
"="))170
(else171
(string-append (base64-char s1)172
(base64-char s2)173
(base64-char s3)174
(base64-char s4))))))175
(loop (+ i 3) (cons chunk acc)))))))177
(define (sasl-plain-payload authcid password . authzid)178
(base64-encode-string179
(string-append180
(if (pair? authzid) (or (car authzid) "") "")181
(string #\null)182
authcid183
(string #\null)184
password)))186
(define (line-without-crlf line)187
(strip-line-ending line))189
(define (send-wire-lines session lines)190
(for-each191
(lambda (line)192
(irc-ws-send session (line-without-crlf line)))193
lines))195
(define (message-command=? msg command)196
(and (irc-message? msg)197
(string=? (symbol->string (irc-message-command msg)) command)))199
(define (cap-subcommand msg)200
(let ((params (irc-message-params msg)))201
(and (pair? params)202
(pair? (cdr params))203
(cadr params))))205
(define (cap-body msg)206
(or (irc-message-trailing msg)207
(let ((params (irc-message-params msg)))208
(if (null? params) "" (car (reverse params))))))210
(define (cap-ls-more? msg)211
(let ((params (irc-message-params msg)))212
(and (>= (length params) 3)213
(string=? (caddr params) "*"))))215
(define (split-caps text)216
(if (string=? text "")217
'()218
(map219
(lambda (entry)220
(let ((eq-pos (string-index entry (lambda (c) (char=? c #\=)))))221
(if eq-pos222
(substring entry 0 eq-pos)223
entry)))224
(string-split text " "))))226
(define (append-new-caps existing names)227
(let loop ((rest names) (acc existing))228
(cond229
((null? rest) acc)230
((member (car rest) acc) (loop (cdr rest) acc))231
(else (loop (cdr rest) (append acc (list (car rest))))))))233
(define (filter-offered desired offered)234
(let loop ((rest desired) (acc '()))235
(cond236
((null? rest) (reverse acc))237
((member (car rest) offered)238
(loop (cdr rest) (cons (car rest) acc)))239
(else (loop (cdr rest) acc)))))241
(define (join-caps caps)242
(string-join caps " "))244
(define (read-next-irc-message session max-lines)245
(let loop ((n 0))246
(if (>= n max-lines)247
#f248
(let ((msg (irc-ws-receive session)))249
(if (irc-message? msg)250
msg251
(loop (+ n 1)))))))253
(define (read-until-command session command max-lines)254
(let loop ((n 0))255
(if (>= n max-lines)256
#f257
(let ((msg (irc-ws-receive session)))258
(cond259
((not (irc-message? msg)) (loop (+ n 1)))260
((message-command=? msg command) msg)261
(else (loop (+ n 1))))))))263
(define (read-cap-ls session max-lines)264
(let loop ((n 0) (offered '()))265
(if (>= n max-lines)266
#f267
(let ((msg (read-next-irc-message session 1)))268
(cond269
((not msg) (loop (+ n 1) offered))270
((and (message-command=? msg "CAP")271
(equal? (cap-subcommand msg) "LS"))272
(let ((next (append-new-caps offered (split-caps (cap-body msg)))))273
(if (cap-ls-more? msg)274
(loop (+ n 1) next)275
next)))276
(else (loop (+ n 1) offered)))))))278
(define (read-cap-ack-or-nak session max-lines)279
(let loop ((n 0))280
(if (>= n max-lines)281
#f282
(let ((msg (read-next-irc-message session 1)))283
(cond284
((not msg) (loop (+ n 1)))285
((and (message-command=? msg "CAP")286
(or (equal? (cap-subcommand msg) "ACK")287
(equal? (cap-subcommand msg) "NAK")))288
msg)289
(else (loop (+ n 1))))))))291
(define (irc-ws-register session)292
(: irc-ws-session? -> void?)293
(let ((nick (irc-ws-session-nick session)))294
(unless nick295
(error "irc-ws-register: session has no nick"))296
(irc-ws-command session "NICK" nick)297
(irc-ws-command session298
"USER"299
(or (irc-ws-session-user session) nick)300
"0"301
"*"302
(or (irc-ws-session-realname session) nick))))304
(define (irc-ws-authenticate-plain session authcid password . authzid)305
(: irc-ws-session? string? string? any? ... -> boolean?)306
(irc-ws-authenticate-plain-start session)307
(if (not (apply irc-ws-authenticate-plain-payload308
session309
authcid310
password311
authzid))312
#f313
(irc-ws-authenticate-finish session)))315
(define (irc-ws-cap-ls session)316
(: irc-ws-session? -> void?)317
(irc-ws-command session "CAP" "LS" "302"))319
(define (irc-ws-cap-req-from-ls session desired . opts)320
(: irc-ws-session? list? any? ... -> list?)321
(let parse-opts ((rest opts)322
(include-sasl? #f)323
(max-lines 80))324
(cond325
((null? rest)326
(let ((offered (read-cap-ls session max-lines)))327
(if (not offered)328
#f329
(let* ((want (if include-sasl?330
(append-new-caps desired (list "sasl"))331
desired))332
(requested (filter-offered want offered)))333
(set-irc-ws-session-server-caps! session offered)334
(if (null? requested)335
(begin336
(irc-ws-cap-end session)337
'())338
(begin339
(irc-ws-command session "CAP" "REQ" (join-caps requested))340
requested))))))341
((and (pair? rest) (eq? (car rest) include-sasl?:))342
(parse-opts (cddr rest) (cadr rest) max-lines))343
((and (pair? rest) (eq? (car rest) max-lines:))344
(parse-opts (cddr rest) include-sasl? (cadr rest)))345
(else346
(error "irc-ws-cap-req-from-ls: unknown option")))))348
(define (irc-ws-cap-ack session . opts)349
(: irc-ws-session? any? ... -> list?)350
(let parse-opts ((rest opts)351
(max-lines 80))352
(cond353
((null? rest)354
(let ((reply (read-cap-ack-or-nak session max-lines)))355
(if (not reply)356
#f357
(let ((acked (if (equal? (cap-subcommand reply) "ACK")358
(split-caps (cap-body reply))359
'())))360
(set-irc-ws-session-enabled-caps! session acked)361
acked))))362
((and (pair? rest) (eq? (car rest) max-lines:))363
(parse-opts (cddr rest) (cadr rest)))364
(else365
(error "irc-ws-cap-ack: unknown option")))))367
(define (irc-ws-cap-end session)368
(: irc-ws-session? -> void?)369
(irc-ws-command session "CAP" "END"))371
(define (irc-ws-authenticate-plain-start session)372
(: irc-ws-session? -> void?)373
(irc-ws-command session "AUTHENTICATE" "PLAIN"))375
(define (irc-ws-authenticate-plain-payload session authcid password . authzid)376
(: irc-ws-session? string? string? any? ... -> boolean?)377
(let ((prompt (read-until-command session "AUTHENTICATE" 40)))378
(if (not prompt)379
#f380
(let ((arg (let ((params (irc-message-params prompt)))381
(and (pair? params) (car params)))))382
(if (not (equal? arg "+"))383
#f384
(begin385
(irc-ws-command session386
"AUTHENTICATE"387
(apply sasl-plain-payload388
authcid389
password390
authzid))391
#t))))))393
(define (irc-ws-authenticate-finish session . opts)394
(: irc-ws-session? any? ... -> boolean?)395
(let parse-opts ((rest opts)396
(max-lines 80))397
(cond398
((null? rest)399
(let ((result400
(let loop ((n 0))401
(if (>= n max-lines)402
#f403
(let ((msg (read-next-irc-message session 1)))404
(cond405
((not msg) (loop (+ n 1)))406
((message-command=? msg "903") msg)407
((or (message-command=? msg "904")408
(message-command=? msg "905")409
(message-command=? msg "906")410
(message-command=? msg "907"))411
msg)412
(else (loop (+ n 1)))))))))413
(cond414
((and result (message-command=? result "903"))415
(set-irc-ws-session-sasl-result! session 'success)416
#t)417
((irc-message? result)418
(set-irc-ws-session-sasl-result!419
session420
(irc-message-command result))421
#f)422
(else #f))))423
((and (pair? rest) (eq? (car rest) max-lines:))424
(parse-opts (cddr rest) (cadr rest)))425
(else426
(error "irc-ws-authenticate-finish: unknown option")))))428
(define (irc-ws-negotiate-caps session desired . opts)429
(: irc-ws-session? list? any? ... -> list?)430
(let parse-opts ((rest opts)431
(sasl-authcid #f)432
(sasl-password #f)433
(max-lines 80))434
(cond435
((null? rest)436
(irc-ws-command session "CAP" "LS" "302")437
(let ((offered (read-cap-ls session max-lines)))438
(if (not offered)439
#f440
(let* ((want (if sasl-password441
(append-new-caps desired (list "sasl"))442
desired))443
(requested (filter-offered want offered)))444
(set-irc-ws-session-server-caps! session offered)445
(if (null? requested)446
(begin447
(irc-ws-command session "CAP" "END")448
'())449
(begin450
(irc-ws-command session "CAP" "REQ" (join-caps requested))451
(let ((reply (read-cap-ack-or-nak session max-lines)))452
(if (not reply)453
#f454
(let ((acked (if (equal? (cap-subcommand reply) "ACK")455
(split-caps (cap-body reply))456
'())))457
(set-irc-ws-session-enabled-caps! session acked)458
(cond459
((and sasl-password (member "sasl" acked))460
(let ((ok (irc-ws-authenticate-plain461
session462
(or sasl-authcid463
(irc-ws-session-nick session))464
sasl-password)))465
(irc-ws-command session "CAP" "END")466
(if ok acked #f)))467
(else468
(irc-ws-command session "CAP" "END")469
acked)))))))))))470
((and (pair? rest) (eq? (car rest) sasl-authcid:))471
(parse-opts (cddr rest) (cadr rest) sasl-password max-lines))472
((and (pair? rest) (eq? (car rest) sasl-password:))473
(parse-opts (cddr rest) sasl-authcid (cadr rest) max-lines))474
((and (pair? rest) (eq? (car rest) max-lines:))475
(parse-opts (cddr rest) sasl-authcid sasl-password (cadr rest)))476
(else477
(error "irc-ws-negotiate-caps: unknown option")))))479
(define (irc-ws-register-with-caps session desired . opts)480
(: irc-ws-session? list? any? ... -> list?)481
(let ((acked (apply irc-ws-negotiate-caps session desired opts)))482
(if (not acked)483
#f484
(begin485
(irc-ws-register session)486
acked))))488
(define (irc-ws-connect . args)489
(: any? ... -> any?)490
(let ((session491
(if (and (pair? args) (irc-ws-session? (car args)))492
(car args)493
(apply make-irc-ws-session args))))494
(when (not (eq? (irc-ws-session-state session) 'disconnected))495
(error "irc-ws-connect: already connected or connecting"))496
(set-irc-ws-session-state! session 'connecting)497
(let ((websocket (ws-connect (irc-ws-session-url session))))498
(if (not websocket)499
(begin500
(set-irc-ws-session-state! session 'disconnected)501
#f)502
(begin503
(set-irc-ws-session-websocket! session websocket)504
(set-irc-ws-session-state! session 'connected)505
session)))))507
(define (irc-ws-connected? session)508
(: irc-ws-session? -> boolean?)509
(and (eq? (irc-ws-session-state session) 'connected)510
(irc-ws-session-websocket session)511
(ws-connected? (irc-ws-session-websocket session))))513
(define (irc-ws-close session)514
(: irc-ws-session? -> void?)515
(when (irc-ws-session-websocket session)516
(ws-close (irc-ws-session-websocket session)))517
(set-irc-ws-session-websocket! session #f)518
(set-irc-ws-session-state! session 'disconnected)519
(set-irc-ws-session-buffer! session "")520
(set-irc-ws-session-queue! session '()))522
(define (irc-ws-send session line)523
(: irc-ws-session? string? -> void?)524
(unless (irc-ws-connected? session)525
(error "irc-ws-send: session is not connected"))526
(ws-send (irc-ws-session-websocket session) line))528
(define (irc-ws-command session command . args)529
(: irc-ws-session? string? string? ... -> void?)530
(irc-ws-send session531
(apply make-irc-command command args)))533
(define (irc-ws-privmsg session target text)534
(: irc-ws-session? string? string? -> void?)535
(irc-ws-send session536
(make-trailing-command "PRIVMSG" target text)))538
(define (irc-ws-notice session target text)539
(: irc-ws-session? string? string? -> void?)540
(irc-ws-send session541
(make-trailing-command "NOTICE" target text)))543
(define (irc-ws-join session channel . key)544
(: irc-ws-session? string? string? ... -> void?)545
(if (pair? key)546
(irc-ws-command session "JOIN" channel (car key))547
(irc-ws-command session "JOIN" channel)))549
(define (irc-ws-part session channel . message)550
(: irc-ws-session? string? string? ... -> void?)551
(if (pair? message)552
(irc-ws-send session553
(make-trailing-command "PART" channel (car message)))554
(irc-ws-command session "PART" channel)))556
(define (irc-ws-quit session . message)557
(: irc-ws-session? string? ... -> void?)558
(when (irc-ws-connected? session)559
(if (pair? message)560
(irc-ws-send session (make-trailing-command "QUIT" (car message)))561
(irc-ws-command session "QUIT")))562
(irc-ws-close session))564
(define (irc-ws-receive session)565
(: irc-ws-session? -> any?)566
(let ((queued (pop-message! session)))567
(if queued568
queued569
(let ((websocket (irc-ws-session-websocket session)))570
(if (not websocket)571
'closed572
(let ((message (ws-receive websocket)))573
(cond574
((ws-message? message)575
(if (eq? (ws-message-type message) 'text)576
(begin577
(enqueue-message! session (ws-message-data message))578
(pop-message! session))579
#f))580
;; Latch the session dead ONLY on a genuine close. This581
;; relies on ws-receive returning #f (not 'closed) while a582
;; browser socket is still in its async CONNECTING window583
;; — otherwise the first poll during that window would584
;; kill a connection that is merely opening slowly (the585
;; browser slow-open bug fixed in (sigil websocket)586
;; ws-receive). Do not relax that invariant without587
;; distinguishing connecting from closed here too.588
((eq? message 'closed)589
(set-irc-ws-session-state! session 'disconnected)590
'closed)591
(else message)))))))593
)))