Commit353473c7Recorded25 Mar 2026Repositorysigil-twitch
Address code review findings: schedule field, chat tests, channel fields, unused dep
Message
- Add canceleduntil field to twitch-schedule-segment record and parser, with JSON null normalization - Add chat module tests: extract parse-chatters-response, parse-chat-settings, build-chat-message-body, build-announcement-body as testable exports; add chat-test.sgl with 9 tests covering parsing and body building - Implement isbrandedcontent and contentclassificationlabels support in twitch-modify-channel - Remove unused sigil-log dependency from package.sgl - Update schedule tests for canceleduntil coverage (3 new tests)
Changed
package.sgl | 3 +--
src/sigil/twitch.sgl | 20 +++++++++++++++++++-
src/sigil/twitch/chat.sgl | 100 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------
src/sigil/twitch/schedule.sgl | 18 +++++++++++-------
test/chat-test.sgl | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/schedule-test.sgl | 31 ++++++++++++++++++++++++++++++-
6 files changed, 274 insertions(+), 47 deletions(-)Diff
package.sglmodified
@@ -30,8 +30,7 @@
30
(from-git url: sigil-repo package: "sigil-tls") 31
(from-git url: sigil-repo package: "sigil-http") 32
(from-git url: sigil-repo package: "sigil-json")−33
(from-git url: sigil-repo package: "sigil-socket")−34
(from-git url: sigil-repo package: "sigil-log"))+33
(from-git url: sigil-repo package: "sigil-socket")) 34
35
tasks: (list 36
(tasksrc/sigil/twitch.sglmodified
@@ -351,7 +351,12 @@
351
;;; Modify channel information (title, game, tags, etc.). 352
;;; Requires channel:manage:broadcast scope. 353
;;; updates is a dict with optional keys: title:, game-id:, tags:,−354
;;; broadcaster-language:, is-branded-content:.+354
;;; broadcaster-language:, is-branded-content:,+355
;;; content-classification-labels:.+356
;;;+357
;;; content-classification-labels: is a list of dicts, each with+358
;;; id: (string) and is-enabled: (boolean).+359
;;; Example: (list #{ id: "MatureGame" is-enabled: #t }) 360
(define (twitch-modify-channel client broadcaster-id updates) 361
(let* ((body (let ((b #{})) 362
(let* ((b (if (dict-ref updates title: #f)@@ -366,6 +371,19 @@
371
(b (if (dict-ref updates broadcaster-language: #f) 372
(dict-set b broadcaster_language: 373
(dict-ref updates broadcaster-language:))+374
b))+375
(b (if (not (eq? (dict-ref updates is-branded-content: 'unset)+376
'unset))+377
(dict-set b is_branded_content:+378
(dict-ref updates is-branded-content:))+379
b))+380
(b (if (dict-ref updates content-classification-labels: #f)+381
(dict-set b content_classification_labels:+382
(list->array+383
(map (lambda (label)+384
#{ id: (dict-ref label id:)+385
is_enabled: (dict-ref label is-enabled:) })+386
(dict-ref updates content-classification-labels:)))) 387
b))) 388
b))) 389
(url (string-appendsrc/sigil/twitch/chat.sglmodified
@@ -12,7 +12,13 @@
12
(sigil http client) 13
(sigil twitch)) 14
−15
(export ;; API functions+15
(export ;; Parsing+16
parse-chatters-response+17
parse-chat-settings+18
build-chat-message-body+19
build-announcement-body+20
+21
;; API functions 22
twitch-send-chat-message 23
twitch-chatters 24
twitch-chat-settings@@ -21,6 +27,55 @@
27
28
(begin 29
+30
;; ---------------------------------------------------------------+31
;; Parsing helpers+32
;; ---------------------------------------------------------------+33
+34
;;; Normalize chatters API response fields from snake_case to kebab-case.+35
(define (parse-chatters-response data)+36
#{ total: (dict-ref data total: 0)+37
data: (map (lambda (item)+38
#{ user-id: (dict-ref item user_id: "")+39
user-login: (dict-ref item user_login: "")+40
user-name: (dict-ref item user_name: "") })+41
(array->list (dict-ref data data: #[])))+42
cursor: (parse-pagination data) })+43
+44
;;; Normalize chat settings response fields from snake_case to kebab-case.+45
(define (parse-chat-settings data)+46
(let ((items (dict-ref data data: #[])))+47
(if (> (array-length items) 0)+48
(let ((s (array-ref items 0)))+49
#{ emote-mode: (dict-ref s emote_mode: #f)+50
follower-mode: (dict-ref s follower_mode: #f)+51
slow-mode: (dict-ref s slow_mode: #f)+52
subscriber-mode: (dict-ref s subscriber_mode: #f)+53
unique-chat-mode: (dict-ref s unique_chat_mode: #f)+54
slow-mode-wait-time:+55
(dict-ref s slow_mode_wait_time: 0)+56
follower-mode-duration:+57
(dict-ref s follower_mode_duration: #f) })+58
#{})))+59
+60
;;; Build chat message request body; exported for testability.+61
(define (build-chat-message-body broadcaster-id sender-id message . rest)+62
(let ((opts (if (null? rest) #{} (car rest))))+63
(let* ((b #{ broadcaster_id: broadcaster-id+64
sender_id: sender-id+65
message: message })+66
(b (if (dict-ref opts reply-parent-message-id: #f)+67
(dict-set b reply_parent_message_id:+68
(dict-ref opts reply-parent-message-id:))+69
b)))+70
b)))+71
+72
;;; Build announcement request body; exported for testability.+73
(define (build-announcement-body message . rest)+74
(let ((color (if (null? rest) #f (car rest))))+75
(if color+76
#{ message: message color: color }+77
#{ message: message })))+78
79
;; --------------------------------------------------------------- 80
;; API functions 81
;; ---------------------------------------------------------------@@ -34,17 +89,10 @@
89
;;; Optional reply-parent-message-id for threading. 90
(define (twitch-send-chat-message client broadcaster-id sender-id message 91
. rest)−37
(let ((opts (if (null? rest) #{} (car rest))))−38
(let* ((body (let* ((b #{ broadcaster_id: broadcaster-id−39
sender_id: sender-id−40
message: message })−41
(b (if (dict-ref opts reply-parent-message-id: #f)−42
(dict-set b reply_parent_message_id:−43
(dict-ref opts reply-parent-message-id:))−44
b)))−45
b))−46
(url (twitch-api-url client "chat" "messages")))−47
(twitch-post/json client url body))))+92
(let* ((opts (if (null? rest) #{} (car rest)))+93
(body (build-chat-message-body broadcaster-id sender-id message opts))+94
(url (twitch-api-url client "chat" "messages")))+95
(twitch-post/json client url body))) 96
97
;;; Get the list of chatters in a channel. 98
;;; Requires moderator:read:chatters scope.@@ -69,13 +117,7 @@
117
(twitch-api-url client "chat" "chatters") 118
(build-query-string params))) 119
(data (twitch-get/json client url)))−72
#{ total: (dict-ref data total: 0)−73
data: (map (lambda (item)−74
#{ user-id: (dict-ref item user_id: "")−75
user-login: (dict-ref item user_login: "")−76
user-name: (dict-ref item user_name: "") })−77
(array->list (dict-ref data data: #[])))−78
cursor: (parse-pagination data) })))+120
(parse-chatters-response data)))) 121
122
;;; Get chat settings for a channel. 123
;;; Returns a dict with chat mode settings.@@ -86,20 +128,8 @@
128
(url (string-append 129
(twitch-api-url client "chat" "settings") 130
(build-query-string params)))−89
(data (twitch-get/json client url))−90
(items (dict-ref data data: #[])))−91
(if (> (array-length items) 0)−92
(let ((s (array-ref items 0)))−93
#{ emote-mode: (dict-ref s emote_mode: #f)−94
follower-mode: (dict-ref s follower_mode: #f)−95
slow-mode: (dict-ref s slow_mode: #f)−96
subscriber-mode: (dict-ref s subscriber_mode: #f)−97
unique-chat-mode: (dict-ref s unique_chat_mode: #f)−98
slow-mode-wait-time:−99
(dict-ref s slow_mode_wait_time: 0)−100
follower-mode-duration:−101
(dict-ref s follower_mode_duration: #f) })−102
#{})))+131
(data (twitch-get/json client url)))+132
(parse-chat-settings data))) 133
134
;;; Update chat settings for a channel. 135
;;; Requires moderator:manage:chat_settings scope.@@ -164,9 +194,7 @@
194
(define (twitch-send-announcement client broadcaster-id moderator-id 195
message . rest) 196
(let* ((color (if (null? rest) #f (car rest)))−167
(body (if color−168
#{ message: message color: color }−169
#{ message: message }))+197
(body (build-announcement-body message color)) 198
(url (string-append 199
(twitch-api-url client "chat" "announcements") 200
(build-query-stringsrc/sigil/twitch/schedule.sglmodified
@@ -22,6 +22,7 @@
22
twitch-schedule-segment-start-time 23
twitch-schedule-segment-end-time 24
twitch-schedule-segment-title+25
twitch-schedule-segment-canceled-until 26
twitch-schedule-segment-category 27
twitch-schedule-segment-is-recurring 28
@@ -47,6 +48,7 @@
48
(start-time default: #f) 49
(end-time default: #f) 50
(title default: "")+51
(canceled-until default: #f) 52
(category default: #{}) 53
(is-recurring default: #f)) 54
@@ -57,13 +59,15 @@
59
;;; Parse a schedule segment from the API response. 60
(define (parse-schedule-segment data) 61
(twitch-schedule-segment−60
id: (dict-ref data id:)−61
start-time: (dict-ref data start_time: #f)−62
end-time: (dict-ref data end_time: #f)−63
title: (dict-ref data title: "")−64
category: (let ((cat (dict-ref data category: #f)))−65
(if cat cat #{}))−66
is-recurring: (dict-ref data is_recurring: #f)))+62
id: (dict-ref data id:)+63
start-time: (dict-ref data start_time: #f)+64
end-time: (dict-ref data end_time: #f)+65
title: (dict-ref data title: "")+66
canceled-until: (let ((v (dict-ref data canceled_until: #f)))+67
(if (or (not v) (eq? v 'null)) #f v))+68
category: (let ((cat (dict-ref data category: #f)))+69
(if cat cat #{}))+70
is-recurring: (dict-ref data is_recurring: #f))) 71
72
;; --------------------------------------------------------------- 73
;; API functionstest/chat-test.sgladded
@@ -0,0 +1,149 @@
+1
;;; Tests for (sigil twitch chat) — chat module+2
;;;+3
;;; Tests response parsing, body building, and data transformation+4
;;; for chat messages, chatters, settings, and announcements.+5
+6
(import (sigil core)+7
(sigil dict)+8
(sigil string)+9
(sigil struct)+10
(sigil json)+11
(sigil test)+12
(sigil twitch)+13
(sigil twitch chat))+14
+15
;; ---------------------------------------------------------------+16
;; Test fixtures — JSON response samples+17
;; ---------------------------------------------------------------+18
+19
(define chatters-response-json+20
(json-decode+21
(string-append+22
"{\"data\": ["+23
" {\"user_id\": \"128393656\","+24
" \"user_login\": \"smittysmithers\","+25
" \"user_name\": \"SmittySmithers\"},"+26
" {\"user_id\": \"141981764\","+27
" \"user_login\": \"twitchdev\","+28
" \"user_name\": \"TwitchDev\"}"+29
"],"+30
" \"pagination\": {\"cursor\": \"eyJiI456\"},"+31
" \"total\": 8}")))+32
+33
(define chatters-empty-json+34
(json-decode+35
(string-append+36
"{\"data\": [],"+37
" \"pagination\": {},"+38
" \"total\": 0}")))+39
+40
(define chat-settings-response-json+41
(json-decode+42
(string-append+43
"{\"data\": [{"+44
" \"emote_mode\": false,"+45
" \"follower_mode\": true,"+46
" \"follower_mode_duration\": 10,"+47
" \"slow_mode\": true,"+48
" \"slow_mode_wait_time\": 30,"+49
" \"subscriber_mode\": false,"+50
" \"unique_chat_mode\": false"+51
"}]}")))+52
+53
(define chat-settings-empty-json+54
(json-decode "{\"data\": []}"))+55
+56
;; ---------------------------------------------------------------+57
;; Chatters response parsing tests+58
;; ---------------------------------------------------------------+59
+60
(test-group "chatters response parsing"+61
+62
(test "parse full chatters response"+63
(let ((result (parse-chatters-response chatters-response-json)))+64
(assert-equal 8 (dict-ref result total:))+65
(assert-equal "eyJiI456" (dict-ref result cursor:))+66
(let ((users (dict-ref result data:)))+67
(assert-equal 2 (length users))+68
(let ((first-user (car users)))+69
(assert-equal "128393656" (dict-ref first-user user-id:))+70
(assert-equal "smittysmithers" (dict-ref first-user user-login:))+71
(assert-equal "SmittySmithers" (dict-ref first-user user-name:)))+72
(let ((second-user (cadr users)))+73
(assert-equal "141981764" (dict-ref second-user user-id:))+74
(assert-equal "twitchdev" (dict-ref second-user user-login:))))))+75
+76
(test "parse empty chatters response"+77
(let ((result (parse-chatters-response chatters-empty-json)))+78
(assert-equal 0 (dict-ref result total:))+79
(assert-equal #f (dict-ref result cursor:))+80
(assert-equal '() (dict-ref result data:)))))+81
+82
;; ---------------------------------------------------------------+83
;; Chat settings parsing tests+84
;; ---------------------------------------------------------------+85
+86
(test-group "chat settings parsing"+87
+88
(test "parse full chat settings response"+89
(let ((result (parse-chat-settings chat-settings-response-json)))+90
(assert-equal #f (dict-ref result emote-mode:))+91
(assert-equal #t (dict-ref result follower-mode:))+92
(assert-equal 10 (dict-ref result follower-mode-duration:))+93
(assert-equal #t (dict-ref result slow-mode:))+94
(assert-equal 30 (dict-ref result slow-mode-wait-time:))+95
(assert-equal #f (dict-ref result subscriber-mode:))+96
(assert-equal #f (dict-ref result unique-chat-mode:))))+97
+98
(test "parse empty chat settings response"+99
(let ((result (parse-chat-settings chat-settings-empty-json)))+100
(assert-true (dict? result))+101
;; Empty data array returns empty dict+102
(assert-equal #f (dict-ref result emote-mode: #f)))))+103
+104
;; ---------------------------------------------------------------+105
;; Chat message body building tests+106
;; ---------------------------------------------------------------+107
+108
(test-group "chat message body building"+109
+110
(test "build basic message body"+111
(let ((body (build-chat-message-body "12345" "67890" "Hello chat!")))+112
(assert-equal "12345" (dict-ref body broadcaster_id:))+113
(assert-equal "67890" (dict-ref body sender_id:))+114
(assert-equal "Hello chat!" (dict-ref body message:))+115
;; No reply ID when not specified+116
(assert-equal #f (dict-ref body reply_parent_message_id: #f))))+117
+118
(test "build reply message body"+119
(let ((body (build-chat-message-body "12345" "67890" "Reply!"+120
#{ reply-parent-message-id: "msg-abc-123" })))+121
(assert-equal "12345" (dict-ref body broadcaster_id:))+122
(assert-equal "67890" (dict-ref body sender_id:))+123
(assert-equal "Reply!" (dict-ref body message:))+124
(assert-equal "msg-abc-123" (dict-ref body reply_parent_message_id:)))))+125
+126
;; ---------------------------------------------------------------+127
;; Announcement body building tests+128
;; ---------------------------------------------------------------+129
+130
(test-group "announcement body building"+131
+132
(test "build announcement without color"+133
(let ((body (build-announcement-body "Important news!")))+134
(assert-equal "Important news!" (dict-ref body message:))+135
(assert-equal #f (dict-ref body color: #f))))+136
+137
(test "build announcement with color"+138
(let ((body (build-announcement-body "Breaking news!" "blue")))+139
(assert-equal "Breaking news!" (dict-ref body message:))+140
(assert-equal "blue" (dict-ref body color:))))+141
+142
(test "build announcement with each valid color"+143
(for-each+144
(lambda (color)+145
(let ((body (build-announcement-body "Test" color)))+146
(assert-equal color (dict-ref body color:))))+147
'("blue" "green" "orange" "purple" "primary"))))+148
+149
(run-tests)test/schedule-test.sglmodified
@@ -22,6 +22,19 @@
22
" \"start_time\": \"2026-03-28T18:00:00Z\"," 23
" \"end_time\": \"2026-03-28T22:00:00Z\"," 24
" \"title\": \"Friday Coding Stream\","+25
" \"canceled_until\": null,"+26
" \"category\": {\"id\": \"509670\","+27
" \"name\": \"Science & Technology\"},"+28
" \"is_recurring\": true}")))+29
+30
(define segment-canceled-json+31
(json-decode+32
(string-append+33
"{\"id\": \"canceled123\","+34
" \"start_time\": \"2026-04-04T18:00:00Z\","+35
" \"end_time\": \"2026-04-04T22:00:00Z\","+36
" \"title\": \"Friday Coding Stream\","+37
" \"canceled_until\": \"2026-04-11T18:00:00Z\"," 38
" \"category\": {\"id\": \"509670\"," 39
" \"name\": \"Science & Technology\"}," 40
" \"is_recurring\": true}")))@@ -57,7 +70,15 @@
70
(assert-equal #f (twitch-schedule-segment-start-time seg)) 71
(assert-equal #f (twitch-schedule-segment-end-time seg)) 72
(assert-equal "" (twitch-schedule-segment-title seg))−60
(assert-equal #f (twitch-schedule-segment-is-recurring seg)))))+73
(assert-equal #f (twitch-schedule-segment-canceled-until seg))+74
(assert-equal #f (twitch-schedule-segment-is-recurring seg))))+75
+76
(test "segment record with canceled-until"+77
(let ((seg (twitch-schedule-segment+78
id: "seg3"+79
canceled-until: "2026-04-11T18:00:00Z")))+80
(assert-equal "2026-04-11T18:00:00Z"+81
(twitch-schedule-segment-canceled-until seg))))) 82
83
;; --------------------------------------------------------------- 84
;; Schedule segment parsing tests@@ -76,12 +97,20 @@
97
(twitch-schedule-segment-end-time seg)) 98
(assert-equal "Friday Coding Stream" 99
(twitch-schedule-segment-title seg))+100
(assert-equal #f (twitch-schedule-segment-canceled-until seg)) 101
(assert-equal #t (twitch-schedule-segment-is-recurring seg)) 102
(let ((cat (twitch-schedule-segment-category seg))) 103
(assert-true (dict? cat)) 104
(assert-equal "509670" (dict-ref cat id:)) 105
(assert-equal "Science & Technology" (dict-ref cat name:))))) 106
+107
(test "parse canceled segment resource"+108
(let ((seg (parse-schedule-segment segment-canceled-json)))+109
(assert-equal "canceled123" (twitch-schedule-segment-id seg))+110
(assert-equal "2026-04-11T18:00:00Z"+111
(twitch-schedule-segment-canceled-until seg))+112
(assert-equal #t (twitch-schedule-segment-is-recurring seg))))+113
114
(test "parse minimal segment resource" 115
(let ((seg (parse-schedule-segment segment-minimal-json))) 116
(assert-equal "abc123" (twitch-schedule-segment-id seg))