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
(task
src/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-append
src/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-string
src/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 functions
test/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))