Commit897e2809Recorded25 Mar 2026Repositorysigil-twitch
Implement Twitch Helix API client library
Message
Five modules covering the core Twitch API surface: - (sigil twitch) — client, auth, HTTP helpers, channels, streams, users, search, cursor-based pagination - (sigil twitch schedule) — schedule segment CRUD, vacation, iCal export - (sigil twitch analytics) — followers, subscribers, clips, VODs, analytics - (sigil twitch chat) — messages, chatters, settings, announcements - (sigil twitch eventsub) — WebSocket message parsing, subscription management
Includes 50 fixture-based tests and comprehensive README with usage examples.
Changed
.gitignore | 3 +
README.md | 205 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
src/sigil/twitch.sgl | 497 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/twitch/analytics.sgl | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/twitch/chat.sgl | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/twitch/eventsub.sgl | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/twitch/schedule.sgl | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/analytics-test.sgl | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++
test/eventsub-test.sgl | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/schedule-test.sgl | 94 +++++++++++++++++++++++++++++++
test/twitch-test.sgl | 324 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 2355 insertions(+), 1 deletion(-)Diff
.gitignoreadded
@@ -0,0 +1,3 @@
+1
build/+2
lib+3
.mcp.jsonREADME.mdmodified
@@ -1,3 +1,206 @@
1
# sigil-twitch 2
−3
Twitch Helix API client library for Sigil 3
No newline at end of file+4
Twitch Helix API client library for Sigil.+5
+6
Provides functions for stream management, scheduling, analytics, chat+7
interaction, and EventSub real-time events.+8
+9
## Features+10
+11
- **Channel management** — get/modify channel info (title, game, tags)+12
- **Stream info** — query live streams, get stream keys+13
- **User lookup** — find users by ID or login+14
- **Search** — search categories and channels+15
- **Schedule** — CRUD for recurring/non-recurring schedule segments, vacation mode, iCal export+16
- **Analytics** — followers, subscribers, clips, VODs, extension/game analytics+17
- **Chat** — send messages, list chatters, manage settings, announcements+18
- **EventSub** — WebSocket message parsing, subscription management, convenience helpers+19
- **Pagination** — generic cursor-based paginator for any list endpoint+20
- **Record types** — typed records for channels, streams, users, clips, videos, schedule segments+21
+22
## Modules+23
+24
| Module | Description |+25
|--------|-------------|+26
| `(sigil twitch)` | Core client, auth, HTTP helpers, channels, streams, users, search, pagination |+27
| `(sigil twitch schedule)` | Schedule segments, vacation, iCalendar export |+28
| `(sigil twitch analytics)` | Followers, subscribers, clips, VODs, extension/game analytics |+29
| `(sigil twitch chat)` | Chat messages, chatters, settings, announcements |+30
| `(sigil twitch eventsub)` | EventSub WebSocket message parsing, subscription management |+31
+32
## Usage+33
+34
### Client Setup+35
+36
```scheme+37
(import (sigil twitch))+38
+39
;; Create a client with your Twitch credentials+40
(define client+41
(twitch-client client-id: "your-client-id"+42
access-token: "your-oauth-token"))+43
+44
;; For local development with the Twitch CLI mock server:+45
(define mock-client+46
(twitch-client client-id: "test-id"+47
access-token: "test-token"+48
base-url: "http://localhost:8080/mock"))+49
```+50
+51
### Channel Info+52
+53
```scheme+54
;; Get channel info (returns a list of twitch-channel records)+55
(let ((channels (twitch-channel-info client "141981764")))+56
(for-each (lambda (ch)+57
(display (twitch-channel-title ch)))+58
channels))+59
+60
;; Modify channel title and category+61
(twitch-modify-channel client "141981764"+62
#{ title: "Building a Twitch bot in Sigil"+63
game-id: "509670" })+64
```+65
+66
### Streams+67
+68
```scheme+69
;; Get live streams for specific users+70
(let ((streams (twitch-streams client+71
#{ user-login: "twitchdev" })))+72
(for-each (lambda (s)+73
(display (twitch-stream-title s))+74
(display (twitch-stream-viewer-count s)))+75
streams))+76
```+77
+78
### Users+79
+80
```scheme+81
;; Look up users by login name+82
(let ((users (twitch-users client #{ login: "twitchdev" })))+83
(for-each (lambda (u)+84
(display (twitch-user-display-name u)))+85
users))+86
```+87
+88
### Pagination+89
+90
```scheme+91
;; Auto-paginate through all followers+92
(import (sigil twitch analytics))+93
+94
(let ((all-followers+95
(twitch-paginate+96
(lambda (cursor)+97
(let ((params (list+98
(cons "broadcaster_id" "141981764")+99
(cons "first" "100")+100
(cons "after" cursor))))+101
(twitch-get/json client+102
(string-append+103
(twitch-api-url client "channels" "followers")+104
(build-query-string params)))))+105
(lambda (item) item) ;; or a custom parse function+106
10))) ;; max 10 pages+107
(display (length all-followers)))+108
```+109
+110
### Schedule+111
+112
```scheme+113
(import (sigil twitch schedule))+114
+115
;; Get the schedule+116
(let ((result (twitch-schedule client "141981764")))+117
(for-each (lambda (seg)+118
(display (twitch-schedule-segment-title seg)))+119
(dict-ref result segments:)))+120
+121
;; Create a recurring weekly segment+122
(twitch-create-segment client "141981764"+123
"2026-04-04T18:00:00Z" "America/New_York"+124
#{ title: "Friday Coding Stream"+125
duration: 240+126
category-id: "509670"+127
is-recurring: #t })+128
```+129
+130
### Chat+131
+132
```scheme+133
(import (sigil twitch chat))+134
+135
;; Send a chat message+136
(twitch-send-chat-message client+137
"141981764" ;; broadcaster-id+138
"12345678" ;; sender-id (your user ID)+139
"Hello from Sigil!")+140
```+141
+142
### EventSub+143
+144
```scheme+145
(import (sigil twitch eventsub))+146
+147
;; After connecting to the WebSocket and receiving a welcome message:+148
(let* ((msg (parse-eventsub-message raw-json))+149
(session (eventsub-session-from-welcome msg))+150
(session-id (twitch-eventsub-session-id session)))+151
+152
;; Subscribe to stream online events+153
(twitch-subscribe-stream-online client session-id "141981764")+154
+155
;; Later, when notifications arrive:+156
(let ((notification (parse-eventsub-message notification-json)))+157
(when (string=? (eventsub-message-type notification) "notification")+158
(let ((event (eventsub-notification-event notification))+159
(type (eventsub-notification-subscription-type notification)))+160
(display type) ;; "stream.online"+161
(display event))))) ;; event data dict+162
```+163
+164
## Authentication+165
+166
All Helix API requests require both a **Client-Id** and a **Bearer access token**.+167
The `twitch-client` record holds both and the library adds the appropriate+168
headers to every request automatically.+169
+170
### Required Scopes+171
+172
| Operation | Scope |+173
|-----------|-------|+174
| Modify channel | `channel:manage:broadcast` |+175
| Read stream key | `channel:read:stream_key` |+176
| Manage schedule | `channel:manage:schedule` |+177
| Read followers | `moderator:read:followers` |+178
| Read subscribers | `channel:read:subscriptions` |+179
| Send chat messages | `user:write:chat` |+180
| Read chat messages (EventSub) | `user:read:chat` |+181
| Create clips | `clips:edit` |+182
| Extension analytics | `analytics:read:extensions` |+183
| Game analytics | `analytics:read:games` |+184
+185
## Building+186
+187
```bash+188
# Requires a C toolchain. On Guix:+189
guix shell -m manifest.scm+190
+191
sigil build --redirects dev-redirects.sgl+192
```+193
+194
## Testing+195
+196
```bash+197
sigil test+198
```+199
+200
## Dependencies+201
+202
- sigil-stdlib+203
- sigil-tls+204
- sigil-http+205
- sigil-json+206
- sigil-socket+207
- sigil-logsrc/sigil/twitch.sgladded
@@ -0,0 +1,497 @@
+1
;;; (sigil twitch) - Twitch Helix API client library.+2
;;;+3
;;; Core module providing authentication, HTTP helpers, record types,+4
;;; and functions for channel management, stream info, user lookup,+5
;;; search, and cursor-based pagination.+6
;;;+7
;;; All Helix API requests require both a Client-Id header and a+8
;;; Bearer access token.+9
+10
(define-library (sigil twitch)+11
(import (sigil core)+12
(sigil dict)+13
(sigil string)+14
(sigil struct)+15
(sigil json)+16
(sigil http client))+17
+18
(export ;; Client+19
twitch-client+20
twitch-client?+21
twitch-client-client-id+22
twitch-client-access-token+23
twitch-client-base-url+24
+25
;; Records — channel+26
twitch-channel+27
twitch-channel?+28
twitch-channel-id+29
twitch-channel-name+30
twitch-channel-game-name+31
twitch-channel-game-id+32
twitch-channel-title+33
twitch-channel-tags+34
+35
;; Records — stream+36
twitch-stream+37
twitch-stream?+38
twitch-stream-id+39
twitch-stream-user-id+40
twitch-stream-user-name+41
twitch-stream-game-name+42
twitch-stream-title+43
twitch-stream-viewer-count+44
twitch-stream-started-at+45
+46
;; Records — user+47
twitch-user+48
twitch-user?+49
twitch-user-id+50
twitch-user-login+51
twitch-user-display-name+52
twitch-user-type+53
twitch-user-broadcaster-type+54
twitch-user-description+55
twitch-user-profile-image-url+56
twitch-user-created-at+57
+58
;; Shared helpers (for sub-modules)+59
twitch-auth-headers+60
twitch-api-url+61
twitch-get/json+62
twitch-post/json+63
twitch-put/json+64
twitch-patch/json+65
twitch-delete/json+66
url-encode-value+67
build-query-string+68
build-repeated-params+69
ensure-list+70
check-twitch-response+71
check-twitch-response/raw+72
+73
;; Parsing+74
parse-channel+75
parse-stream+76
parse-user+77
parse-pagination+78
+79
;; Pagination+80
twitch-paginate+81
+82
;; API functions+83
twitch-channel-info+84
twitch-modify-channel+85
twitch-streams+86
twitch-stream-key+87
twitch-search-categories+88
twitch-search-channels+89
twitch-users)+90
+91
(begin+92
+93
;; ---------------------------------------------------------------+94
;; Records+95
;; ---------------------------------------------------------------+96
+97
(define-struct twitch-client+98
(client-id)+99
(access-token)+100
(base-url default: "https://api.twitch.tv/helix"))+101
+102
(define-struct twitch-channel+103
(id)+104
(name default: "")+105
(game-name default: "")+106
(game-id default: "")+107
(title default: "")+108
(tags default: '()))+109
+110
(define-struct twitch-stream+111
(id)+112
(user-id default: "")+113
(user-name default: "")+114
(game-name default: "")+115
(title default: "")+116
(viewer-count default: 0)+117
(started-at default: #f))+118
+119
(define-struct twitch-user+120
(id)+121
(login default: "")+122
(display-name default: "")+123
(type default: "")+124
(broadcaster-type default: "")+125
(description default: "")+126
(profile-image-url default: "")+127
(created-at default: #f))+128
+129
;; ---------------------------------------------------------------+130
;; Internal helpers+131
;; ---------------------------------------------------------------+132
+133
;;; Build auth headers with both Client-Id and Bearer token.+134
(define (twitch-auth-headers client)+135
#{ authorization: (string-append "Bearer " (twitch-client-access-token client))+136
client-id: (twitch-client-client-id client) })+137
+138
(define (twitch-api-url client . parts)+139
(apply string-append (twitch-client-base-url client)+140
(map (lambda (p) (string-append "/" p)) parts)))+141
+142
;; No shared url-encode exists in sigil-http (known gap).+143
(define (url-encode-value s)+144
(let ((len (string-length s)))+145
(let loop ((i 0) (acc '()))+146
(if (>= i len)+147
(list->string (reverse acc))+148
(let ((c (string-ref s i)))+149
(cond+150
((or (char-alphabetic? c)+151
(char-numeric? c)+152
(char=? c #\-)+153
(char=? c #\_)+154
(char=? c #\.)+155
(char=? c #\~))+156
(loop (+ i 1) (cons c acc)))+157
((char=? c #\space)+158
(loop (+ i 1) (cons #\+ acc)))+159
(else+160
(let ((n (char->integer c)))+161
(loop (+ i 1)+162
(append (reverse (string->list+163
(string-append "%"+164
(if (< n 16) "0" "")+165
(number->string n 16))))+166
acc))))))))))+167
+168
;;; Build a query string from (key . value) pairs.+169
;;; Pairs with #f values are omitted.+170
(define (build-query-string params)+171
(let ((parts (filter (lambda (p) (cdr p)) params)))+172
(if (null? parts)+173
""+174
(string-append "?"+175
(string-join+176
(map (lambda (p)+177
(string-append (car p) "=" (url-encode-value (cdr p))))+178
parts)+179
"&")))))+180
+181
;;; Build repeated query params for Twitch-style multi-value parameters.+182
;;; e.g., (build-repeated-params "id" '("1" "2")) => "id=1&id=2"+183
(define (build-repeated-params key values)+184
(string-join+185
(map (lambda (v) (string-append key "=" (url-encode-value v))) values)+186
"&"))+187
+188
;;; Normalize a value to a list — if already a list, return as-is;+189
;;; if a string, wrap in a single-element list.+190
(define (ensure-list v)+191
(if (string? v) (list v) v))+192
+193
;;; Check an HTTP response for errors and return the raw body string.+194
;;; Raises Twitch-specific errors for 401/403/429/4xx status codes.+195
(define (check-twitch-response/raw response)+196
(if (not (http-response? response))+197
(error "Twitch API request failed: no response"))+198
(let ((status (http-response-status response))+199
(body (http-response-body response)))+200
(cond+201
((= status 401)+202
(error (string-append+203
"Twitch API 401 Unauthorized. "+204
"Access token may be expired or invalid. "+205
"Response: " (or body ""))))+206
((= status 403)+207
(error (string-append+208
"Twitch API 403 Forbidden. "+209
"Insufficient permissions or missing scope. "+210
"Response: " (or body ""))))+211
((= status 429)+212
(error (string-append+213
"Twitch API 429 rate limited. "+214
"Check Ratelimit-Reset header and retry. "+215
"Response: " (or body ""))))+216
((>= status 400)+217
(error (string-append+218
"Twitch API error " (number->string status) ": "+219
(or body ""))))+220
(else body))))+221
+222
;;; Check an HTTP response and return parsed JSON.+223
(define (check-twitch-response response)+224
(let ((body (check-twitch-response/raw response)))+225
(if (and body (not (string=? body "")))+226
(json-decode body)+227
#t)))+228
+229
;;; Authenticated JSON GET request.+230
(define (twitch-get/json client url)+231
(check-twitch-response+232
(http-get url headers: (twitch-auth-headers client))))+233
+234
;;; Authenticated JSON POST request.+235
(define (twitch-post/json client url body)+236
(check-twitch-response+237
(http-post url (if (string? body) body (json-encode body))+238
headers: (dict-merge (twitch-auth-headers client)+239
#{ content-type: "application/json" }))))+240
+241
;;; Authenticated JSON PUT request.+242
(define (twitch-put/json client url body)+243
(check-twitch-response+244
(http-put url (if (string? body) body (json-encode body))+245
headers: (dict-merge (twitch-auth-headers client)+246
#{ content-type: "application/json" }))))+247
+248
;;; Authenticated JSON PATCH request.+249
(define (twitch-patch/json client url body)+250
(check-twitch-response+251
(http-patch url (if (string? body) body (json-encode body))+252
headers: (dict-merge (twitch-auth-headers client)+253
#{ content-type: "application/json" }))))+254
+255
;;; Authenticated DELETE request.+256
(define (twitch-delete/json client url)+257
(check-twitch-response+258
(http-delete url headers: (twitch-auth-headers client))))+259
+260
;; ---------------------------------------------------------------+261
;; Response parsing+262
;; ---------------------------------------------------------------+263
+264
;;; Extract pagination cursor from a Twitch list response.+265
;;; Returns the cursor string or #f if no more pages.+266
(define (parse-pagination data)+267
(let ((pag (dict-ref data pagination: #{})))+268
(dict-ref pag cursor: #f)))+269
+270
;;; Parse a channel resource into a twitch-channel record.+271
(define (parse-channel data)+272
(twitch-channel+273
id: (dict-ref data broadcaster_id: "")+274
name: (dict-ref data broadcaster_name:+275
(dict-ref data broadcaster_login: ""))+276
game-name: (dict-ref data game_name: "")+277
game-id: (dict-ref data game_id: "")+278
title: (dict-ref data title: "")+279
tags: (let ((t (dict-ref data tags: #f)))+280
(if (and t (array? t))+281
(array->list t)+282
'()))))+283
+284
;;; Parse a stream resource into a twitch-stream record.+285
(define (parse-stream data)+286
(twitch-stream+287
id: (dict-ref data id:)+288
user-id: (dict-ref data user_id: "")+289
user-name: (dict-ref data user_name: "")+290
game-name: (dict-ref data game_name: "")+291
title: (dict-ref data title: "")+292
viewer-count: (dict-ref data viewer_count: 0)+293
started-at: (dict-ref data started_at: #f)))+294
+295
;;; Parse a user resource into a twitch-user record.+296
(define (parse-user data)+297
(twitch-user+298
id: (dict-ref data id:)+299
login: (dict-ref data login: "")+300
display-name: (dict-ref data display_name: "")+301
type: (dict-ref data type: "")+302
broadcaster-type: (dict-ref data broadcaster_type: "")+303
description: (dict-ref data description: "")+304
profile-image-url: (dict-ref data profile_image_url: "")+305
created-at: (dict-ref data created_at: #f)))+306
+307
;; ---------------------------------------------------------------+308
;; Pagination helper+309
;; ---------------------------------------------------------------+310
+311
;;; Generic cursor-based paginator.+312
;;; Fetches all pages from a Twitch endpoint and returns a flat+313
;;; list of parsed items.+314
;;;+315
;;; fetch-fn: (lambda (cursor) ...) — returns raw API response dict+316
;;; parse-fn: (lambda (item) ...) — parses a single item from the+317
;;; data array+318
;;; Optional max-pages: limit the number of pages fetched (default: 100)+319
(define (twitch-paginate fetch-fn parse-fn . rest)+320
(let ((max-pages (if (null? rest) 100 (car rest))))+321
(let loop ((cursor #f) (acc '()) (page 0))+322
(if (>= page max-pages)+323
(apply append (reverse acc))+324
(let* ((data (fetch-fn cursor))+325
(items (dict-ref data data: #[]))+326
(parsed (map parse-fn (array->list items)))+327
(next-cursor (parse-pagination data))+328
(new-acc (cons parsed acc)))+329
(if (or (not next-cursor)+330
(string=? next-cursor ""))+331
(apply append (reverse new-acc))+332
(loop next-cursor new-acc (+ page 1))))))))+333
+334
;; ---------------------------------------------------------------+335
;; API functions+336
;; ---------------------------------------------------------------+337
+338
;;; Get channel information by broadcaster ID(s).+339
;;; broadcaster-id: a single ID string or list of ID strings.+340
;;; Returns a list of twitch-channel records.+341
(define (twitch-channel-info client broadcaster-id)+342
(let* ((ids (ensure-list broadcaster-id))+343
(id-params (build-repeated-params "broadcaster_id" ids))+344
(url (string-append+345
(twitch-api-url client "channels")+346
"?" id-params))+347
(data (twitch-get/json client url))+348
(items (dict-ref data data: #[])))+349
(map parse-channel (array->list items))))+350
+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:.+355
(define (twitch-modify-channel client broadcaster-id updates)+356
(let* ((body (let ((b #{}))+357
(let* ((b (if (dict-ref updates title: #f)+358
(dict-set b title: (dict-ref updates title:))+359
b))+360
(b (if (dict-ref updates game-id: #f)+361
(dict-set b game_id: (dict-ref updates game-id:))+362
b))+363
(b (if (dict-ref updates tags: #f)+364
(dict-set b tags: (list->array (dict-ref updates tags:)))+365
b))+366
(b (if (dict-ref updates broadcaster-language: #f)+367
(dict-set b broadcaster_language:+368
(dict-ref updates broadcaster-language:))+369
b)))+370
b)))+371
(url (string-append+372
(twitch-api-url client "channels")+373
(build-query-string+374
(list (cons "broadcaster_id" broadcaster-id))))))+375
(twitch-patch/json client url body)))+376
+377
;;; Get live streams. Filter by user IDs, user logins, game IDs, or language.+378
;;; Optional opts dict:+379
;;; user-id: string or list of strings+380
;;; user-login: string or list of strings+381
;;; game-id: string or list of strings+382
;;; first: number (1-100, default: 20)+383
;;; after: pagination cursor+384
(define (twitch-streams client . rest)+385
(let ((opts (if (null? rest) #{} (car rest))))+386
(let* ((params (list+387
(cons "first"+388
(if (dict-ref opts first: #f)+389
(number->string (dict-ref opts first:))+390
#f))+391
(cons "after" (dict-ref opts after: #f))))+392
(query-str (build-query-string params))+393
(base-url (string-append+394
(twitch-api-url client "streams") query-str))+395
(has-params (not (string=? query-str "")))+396
;; Collect repeated params for multi-value fields+397
(repeated '())+398
(repeated (let ((uid (dict-ref opts user-id: #f)))+399
(if uid+400
(cons (build-repeated-params+401
"user_id" (ensure-list uid))+402
repeated)+403
repeated)))+404
(repeated (let ((login (dict-ref opts user-login: #f)))+405
(if login+406
(cons (build-repeated-params+407
"user_login" (ensure-list login))+408
repeated)+409
repeated)))+410
(repeated (let ((gid (dict-ref opts game-id: #f)))+411
(if gid+412
(cons (build-repeated-params+413
"game_id" (ensure-list gid))+414
repeated)+415
repeated)))+416
(url (if (null? repeated)+417
base-url+418
(string-append base-url+419
(if has-params "&" "?")+420
(string-join (reverse repeated) "&"))))+421
(data (twitch-get/json client url))+422
(items (dict-ref data data: #[])))+423
(map parse-stream (array->list items)))))+424
+425
;;; Get the stream key for a channel.+426
;;; Requires channel:read:stream_key scope.+427
(define (twitch-stream-key client broadcaster-id)+428
(let* ((url (string-append+429
(twitch-api-url client "streams" "key")+430
(build-query-string+431
(list (cons "broadcaster_id" broadcaster-id)))))+432
(data (twitch-get/json client url))+433
(items (dict-ref data data: #[])))+434
(if (> (array-length items) 0)+435
(dict-ref (array-ref items 0) stream_key: #f)+436
#f)))+437
+438
;;; Search for categories/games.+439
;;; Returns raw data array items with id, name, box_art_url.+440
(define (twitch-search-categories client query . rest)+441
(let ((opts (if (null? rest) #{} (car rest))))+442
(let* ((params (list+443
(cons "query" query)+444
(cons "first"+445
(number->string (dict-ref opts first: 20)))+446
(cons "after"+447
(dict-ref opts after: #f))))+448
(url (string-append+449
(twitch-api-url client "search" "categories")+450
(build-query-string params)))+451
(data (twitch-get/json client url)))+452
data)))+453
+454
;;; Search for channels.+455
;;; Returns raw data with broadcaster info.+456
(define (twitch-search-channels client query . rest)+457
(let ((opts (if (null? rest) #{} (car rest))))+458
(let* ((params (list+459
(cons "query" query)+460
(cons "first"+461
(number->string (dict-ref opts first: 20)))+462
(cons "live_only"+463
(if (dict-ref opts live-only: #f) "true" #f))+464
(cons "after"+465
(dict-ref opts after: #f))))+466
(url (string-append+467
(twitch-api-url client "search" "channels")+468
(build-query-string params)))+469
(data (twitch-get/json client url)))+470
data)))+471
+472
;;; Get users by ID or login.+473
;;; Pass id: (string or list) and/or login: (string or list).+474
(define (twitch-users client . rest)+475
(let ((opts (if (null? rest) #{} (car rest))))+476
(let* ((base-url (twitch-api-url client "users"))+477
(parts '())+478
(parts (let ((ids (dict-ref opts id: #f)))+479
(if ids+480
(cons (build-repeated-params "id" (ensure-list ids))+481
parts)+482
parts)))+483
(parts (let ((logins (dict-ref opts login: #f)))+484
(if logins+485
(cons (build-repeated-params "login"+486
(ensure-list logins))+487
parts)+488
parts)))+489
(query-str (if (null? parts) ""+490
(string-append "?"+491
(string-join (reverse parts) "&"))))+492
(url (string-append base-url query-str))+493
(data (twitch-get/json client url))+494
(items (dict-ref data data: #[])))+495
(map parse-user (array->list items)))))+496
+497
))src/sigil/twitch/analytics.sgladded
@@ -0,0 +1,308 @@
+1
;;; (sigil twitch analytics) - Twitch analytics, followers, subscribers,+2
;;; clips, and videos.+3
;;;+4
;;; Provides access to follower/subscriber data, clip management,+5
;;; VOD/highlight retrieval, and extension/game analytics.+6
+7
(define-library (sigil twitch analytics)+8
(import (sigil core)+9
(sigil dict)+10
(sigil string)+11
(sigil struct)+12
(sigil json)+13
(sigil http client)+14
(sigil twitch))+15
+16
(export ;; Records+17
twitch-clip+18
twitch-clip?+19
twitch-clip-id+20
twitch-clip-url+21
twitch-clip-creator-name+22
twitch-clip-video-id+23
twitch-clip-game-id+24
twitch-clip-title+25
twitch-clip-view-count+26
twitch-clip-duration+27
+28
twitch-video+29
twitch-video?+30
twitch-video-id+31
twitch-video-user-id+32
twitch-video-title+33
twitch-video-type+34
twitch-video-duration+35
twitch-video-view-count+36
twitch-video-created-at+37
+38
;; Parsing+39
parse-clip+40
parse-video+41
+42
;; API functions+43
twitch-followers+44
twitch-subscribers+45
twitch-clips+46
twitch-create-clip+47
twitch-videos+48
twitch-extension-analytics+49
twitch-game-analytics)+50
+51
(begin+52
+53
;; ---------------------------------------------------------------+54
;; Records+55
;; ---------------------------------------------------------------+56
+57
(define-struct twitch-clip+58
(id)+59
(url default: "")+60
(creator-name default: "")+61
(video-id default: "")+62
(game-id default: "")+63
(title default: "")+64
(view-count default: 0)+65
(duration default: 0))+66
+67
(define-struct twitch-video+68
(id)+69
(user-id default: "")+70
(title default: "")+71
(type default: "")+72
(duration default: "")+73
(view-count default: 0)+74
(created-at default: #f))+75
+76
;; ---------------------------------------------------------------+77
;; Parsing+78
;; ---------------------------------------------------------------+79
+80
(define (parse-clip data)+81
(twitch-clip+82
id: (dict-ref data id:)+83
url: (dict-ref data url: "")+84
creator-name: (dict-ref data creator_name: "")+85
video-id: (dict-ref data video_id: "")+86
game-id: (dict-ref data game_id: "")+87
title: (dict-ref data title: "")+88
view-count: (dict-ref data view_count: 0)+89
duration: (dict-ref data duration: 0)))+90
+91
(define (parse-video data)+92
(twitch-video+93
id: (dict-ref data id:)+94
user-id: (dict-ref data user_id: "")+95
title: (dict-ref data title: "")+96
type: (dict-ref data type: "")+97
duration: (dict-ref data duration: "")+98
view-count: (dict-ref data view_count: 0)+99
created-at: (dict-ref data created_at: #f)))+100
+101
;; ---------------------------------------------------------------+102
;; API functions+103
;; ---------------------------------------------------------------+104
+105
;;; Get followers for a channel.+106
;;; Requires moderator:read:followers scope for individual records.+107
;;; Always returns total count.+108
;;;+109
;;; Optional opts dict:+110
;;; first: number (1-100, default: 20)+111
;;; after: pagination cursor+112
;;; user-id: check if specific user follows+113
(define (twitch-followers client broadcaster-id . rest)+114
(let ((opts (if (null? rest) #{} (car rest))))+115
(let* ((params (list+116
(cons "broadcaster_id" broadcaster-id)+117
(cons "first"+118
(if (dict-ref opts first: #f)+119
(number->string (dict-ref opts first:))+120
#f))+121
(cons "after"+122
(dict-ref opts after: #f))+123
(cons "user_id"+124
(dict-ref opts user-id: #f))))+125
(url (string-append+126
(twitch-api-url client "channels" "followers")+127
(build-query-string params)))+128
(data (twitch-get/json client url)))+129
#{ total: (dict-ref data total: 0)+130
data: (map (lambda (item)+131
#{ user-id: (dict-ref item user_id: "")+132
user-name: (dict-ref item user_name: "")+133
followed-at: (dict-ref item followed_at: #f) })+134
(array->list (dict-ref data data: #[])))+135
cursor: (parse-pagination data) })))+136
+137
;;; Get subscribers for a channel.+138
;;; Requires channel:read:subscriptions scope.+139
;;;+140
;;; Optional opts dict:+141
;;; first: number (1-100, default: 20)+142
;;; after: pagination cursor+143
;;; user-id: check if specific user is subscribed+144
(define (twitch-subscribers client broadcaster-id . rest)+145
(let ((opts (if (null? rest) #{} (car rest))))+146
(let* ((params (list+147
(cons "broadcaster_id" broadcaster-id)+148
(cons "first"+149
(if (dict-ref opts first: #f)+150
(number->string (dict-ref opts first:))+151
#f))+152
(cons "after"+153
(dict-ref opts after: #f))+154
(cons "user_id"+155
(dict-ref opts user-id: #f))))+156
(url (string-append+157
(twitch-api-url client "subscriptions")+158
(build-query-string params)))+159
(data (twitch-get/json client url)))+160
#{ total: (dict-ref data total: 0)+161
data: (map (lambda (item)+162
#{ user-id: (dict-ref item user_id: "")+163
user-name: (dict-ref item user_name: "")+164
tier: (dict-ref item tier: "")+165
is-gift: (dict-ref item is_gift: #f) })+166
(array->list (dict-ref data data: #[])))+167
cursor: (parse-pagination data) })))+168
+169
;;; Get clips for a broadcaster, game, or specific clip IDs.+170
;;;+171
;;; Optional opts dict:+172
;;; broadcaster-id: filter by broadcaster+173
;;; game-id: filter by game+174
;;; id: specific clip ID or list of IDs+175
;;; started-at: ISO 8601 start date+176
;;; ended-at: ISO 8601 end date+177
;;; first: number (1-100, default: 20)+178
;;; after: pagination cursor+179
(define (twitch-clips client . rest)+180
(let ((opts (if (null? rest) #{} (car rest))))+181
(let* ((params (list+182
(cons "broadcaster_id"+183
(dict-ref opts broadcaster-id: #f))+184
(cons "game_id"+185
(dict-ref opts game-id: #f))+186
(cons "started_at"+187
(dict-ref opts started-at: #f))+188
(cons "ended_at"+189
(dict-ref opts ended-at: #f))+190
(cons "first"+191
(if (dict-ref opts first: #f)+192
(number->string (dict-ref opts first:))+193
#f))+194
(cons "after"+195
(dict-ref opts after: #f))))+196
(query-str (build-query-string params))+197
(base-url (string-append+198
(twitch-api-url client "clips") query-str))+199
(url (let ((clip-id (dict-ref opts id: #f)))+200
(if clip-id+201
(string-append base-url+202
(if (string=? query-str "") "?" "&")+203
(build-repeated-params "id"+204
(ensure-list clip-id)))+205
base-url)))+206
(data (twitch-get/json client url))+207
(items (dict-ref data data: #[])))+208
(map parse-clip (array->list items)))))+209
+210
;;; Create a clip from a live stream or recent VOD.+211
;;; Requires clips:edit scope.+212
;;; Returns a dict with id: and edit-url:.+213
(define (twitch-create-clip client broadcaster-id)+214
(let* ((url (string-append+215
(twitch-api-url client "clips")+216
(build-query-string+217
(list (cons "broadcaster_id" broadcaster-id)))))+218
(data (twitch-post/json client url #{}))+219
(items (dict-ref data data: #[])))+220
(if (> (array-length items) 0)+221
(let ((item (array-ref items 0)))+222
#{ id: (dict-ref item id:)+223
edit-url: (dict-ref item edit_url: "") })+224
#f)))+225
+226
;;; Get videos (VODs, highlights, uploads) for a user or game.+227
;;;+228
;;; Optional opts dict:+229
;;; user-id: filter by user+230
;;; game-id: filter by game+231
;;; id: specific video ID or list of IDs+232
;;; type: "all", "archive", "highlight", "upload" (default: "all")+233
;;; sort: "time", "trending", "views" (default: "time")+234
;;; period: "all", "day", "week", "month" (default: "all")+235
;;; first: number (1-100, default: 20)+236
;;; after: pagination cursor+237
(define (twitch-videos client . rest)+238
(let ((opts (if (null? rest) #{} (car rest))))+239
(let* ((params (list+240
(cons "user_id"+241
(dict-ref opts user-id: #f))+242
(cons "game_id"+243
(dict-ref opts game-id: #f))+244
(cons "type"+245
(dict-ref opts type: #f))+246
(cons "sort"+247
(dict-ref opts sort: #f))+248
(cons "period"+249
(dict-ref opts period: #f))+250
(cons "first"+251
(if (dict-ref opts first: #f)+252
(number->string (dict-ref opts first:))+253
#f))+254
(cons "after"+255
(dict-ref opts after: #f))))+256
(query-str (build-query-string params))+257
(base-url (string-append+258
(twitch-api-url client "videos") query-str))+259
(url (let ((vid-id (dict-ref opts id: #f)))+260
(if vid-id+261
(string-append base-url+262
(if (string=? query-str "") "?" "&")+263
(build-repeated-params "id"+264
(ensure-list vid-id)))+265
base-url)))+266
(data (twitch-get/json client url))+267
(items (dict-ref data data: #[])))+268
(map parse-video (array->list items)))))+269
+270
;;; Internal helper for analytics endpoints (extensions/games).+271
;;; Both share the same structure, differing only in endpoint path+272
;;; and the ID parameter key.+273
(define (twitch-analytics client endpoint id-key opts)+274
(let* ((params (list+275
(cons "started_at"+276
(dict-ref opts started-at: #f))+277
(cons "ended_at"+278
(dict-ref opts ended-at: #f))+279
(cons "first"+280
(if (dict-ref opts first: #f)+281
(number->string (dict-ref opts first:))+282
#f))+283
(cons "after"+284
(dict-ref opts after: #f))+285
(cons id-key+286
(dict-ref opts id: #f))+287
(cons "type"+288
(dict-ref opts type: #f))))+289
(url (string-append+290
(twitch-api-url client "analytics" endpoint)+291
(build-query-string params))))+292
(twitch-get/json client url)))+293
+294
;;; Get extension analytics.+295
;;; Requires analytics:read:extensions scope.+296
;;; Returns raw response with download URLs for CSV reports.+297
(define (twitch-extension-analytics client . rest)+298
(twitch-analytics client "extensions" "extension_id"+299
(if (null? rest) #{} (car rest))))+300
+301
;;; Get game analytics.+302
;;; Requires analytics:read:games scope.+303
;;; Returns raw response with download URLs for CSV reports.+304
(define (twitch-game-analytics client . rest)+305
(twitch-analytics client "games" "game_id"+306
(if (null? rest) #{} (car rest))))+307
+308
))src/sigil/twitch/chat.sgladded
@@ -0,0 +1,177 @@
+1
;;; (sigil twitch chat) - Twitch chat operations.+2
;;;+3
;;; Send chat messages, list chatters, manage chat settings,+4
;;; and send announcements via the Helix REST API.+5
+6
(define-library (sigil twitch chat)+7
(import (sigil core)+8
(sigil dict)+9
(sigil string)+10
(sigil struct)+11
(sigil json)+12
(sigil http client)+13
(sigil twitch))+14
+15
(export ;; API functions+16
twitch-send-chat-message+17
twitch-chatters+18
twitch-chat-settings+19
twitch-update-chat-settings+20
twitch-send-announcement)+21
+22
(begin+23
+24
;; ---------------------------------------------------------------+25
;; API functions+26
;; ---------------------------------------------------------------+27
+28
;;; Send a chat message to a channel.+29
;;; Requires user:write:chat scope.+30
;;;+31
;;; broadcaster-id: channel to send to+32
;;; sender-id: the authenticated user's ID+33
;;; message: text to send+34
;;; Optional reply-parent-message-id for threading.+35
(define (twitch-send-chat-message client broadcaster-id sender-id message+36
. 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))))+48
+49
;;; Get the list of chatters in a channel.+50
;;; Requires moderator:read:chatters scope.+51
;;;+52
;;; broadcaster-id: the channel+53
;;; moderator-id: must be the broadcaster or a moderator+54
;;; Optional opts dict:+55
;;; first: number (1-1000, default: 100)+56
;;; after: pagination cursor+57
(define (twitch-chatters client broadcaster-id moderator-id . rest)+58
(let ((opts (if (null? rest) #{} (car rest))))+59
(let* ((params (list+60
(cons "broadcaster_id" broadcaster-id)+61
(cons "moderator_id" moderator-id)+62
(cons "first"+63
(if (dict-ref opts first: #f)+64
(number->string (dict-ref opts first:))+65
#f))+66
(cons "after"+67
(dict-ref opts after: #f))))+68
(url (string-append+69
(twitch-api-url client "chat" "chatters")+70
(build-query-string params)))+71
(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) })))+79
+80
;;; Get chat settings for a channel.+81
;;; Returns a dict with chat mode settings.+82
(define (twitch-chat-settings client broadcaster-id moderator-id)+83
(let* ((params (list+84
(cons "broadcaster_id" broadcaster-id)+85
(cons "moderator_id" moderator-id)))+86
(url (string-append+87
(twitch-api-url client "chat" "settings")+88
(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
#{})))+103
+104
;;; Update chat settings for a channel.+105
;;; Requires moderator:manage:chat_settings scope.+106
;;;+107
;;; settings dict may contain:+108
;;; emote-mode: boolean+109
;;; follower-mode: boolean+110
;;; follower-mode-duration: minutes (0 = any follower)+111
;;; slow-mode: boolean+112
;;; slow-mode-wait-time: seconds (3-120)+113
;;; subscriber-mode: boolean+114
;;; unique-chat-mode: boolean+115
(define (twitch-update-chat-settings client broadcaster-id moderator-id+116
settings)+117
(let* ((body (let* ((b #{})+118
(b (if (not (eq? (dict-ref settings emote-mode: 'unset)+119
'unset))+120
(dict-set b emote_mode:+121
(dict-ref settings emote-mode:))+122
b))+123
(b (if (not (eq? (dict-ref settings follower-mode: 'unset)+124
'unset))+125
(dict-set b follower_mode:+126
(dict-ref settings follower-mode:))+127
b))+128
(b (if (dict-ref settings follower-mode-duration: #f)+129
(dict-set b follower_mode_duration:+130
(dict-ref settings follower-mode-duration:))+131
b))+132
(b (if (not (eq? (dict-ref settings slow-mode: 'unset)+133
'unset))+134
(dict-set b slow_mode:+135
(dict-ref settings slow-mode:))+136
b))+137
(b (if (dict-ref settings slow-mode-wait-time: #f)+138
(dict-set b slow_mode_wait_time:+139
(dict-ref settings slow-mode-wait-time:))+140
b))+141
(b (if (not (eq? (dict-ref settings subscriber-mode: 'unset)+142
'unset))+143
(dict-set b subscriber_mode:+144
(dict-ref settings subscriber-mode:))+145
b))+146
(b (if (not (eq? (dict-ref settings unique-chat-mode: 'unset)+147
'unset))+148
(dict-set b unique_chat_mode:+149
(dict-ref settings unique-chat-mode:))+150
b)))+151
b))+152
(url (string-append+153
(twitch-api-url client "chat" "settings")+154
(build-query-string+155
(list (cons "broadcaster_id" broadcaster-id)+156
(cons "moderator_id" moderator-id))))))+157
(twitch-patch/json client url body)))+158
+159
;;; Send an announcement in chat.+160
;;; Requires moderator:manage:announcements scope.+161
;;;+162
;;; message: announcement text+163
;;; Optional color: "blue", "green", "orange", "purple", "primary"+164
(define (twitch-send-announcement client broadcaster-id moderator-id+165
message . rest)+166
(let* ((color (if (null? rest) #f (car rest)))+167
(body (if color+168
#{ message: message color: color }+169
#{ message: message }))+170
(url (string-append+171
(twitch-api-url client "chat" "announcements")+172
(build-query-string+173
(list (cons "broadcaster_id" broadcaster-id)+174
(cons "moderator_id" moderator-id))))))+175
(twitch-post/json client url body)))+176
+177
))src/sigil/twitch/eventsub.sgladded
@@ -0,0 +1,192 @@
+1
;;; (sigil twitch eventsub) - Twitch EventSub WebSocket client.+2
;;;+3
;;; Connects to the Twitch EventSub WebSocket endpoint to receive+4
;;; real-time push notifications for stream and chat events.+5
;;;+6
;;; Lifecycle:+7
;;; 1. Connect to wss://eventsub.wss.twitch.tv/ws+8
;;; 2. Receive session_welcome with session ID+9
;;; 3. Subscribe to event types using the session ID via REST API+10
;;; 4. Receive notification messages with event data+11
;;; 5. Handle reconnect messages when server requests migration+12
;;;+13
;;; Gotchas:+14
;;; - Do NOT send any messages on the WebSocket (except pong)+15
;;; - Must subscribe within the keepalive timeout or connection closes+16
;;; - Max 3 connections per user token, 300 subscriptions per connection+17
+18
(define-library (sigil twitch eventsub)+19
(import (sigil core)+20
(sigil dict)+21
(sigil string)+22
(sigil struct)+23
(sigil json)+24
(sigil http client)+25
(sigil twitch))+26
+27
(export ;; Records+28
twitch-eventsub-session+29
twitch-eventsub-session?+30
twitch-eventsub-session-id+31
twitch-eventsub-session-status+32
twitch-eventsub-session-keepalive-timeout+33
twitch-eventsub-session-reconnect-url+34
+35
;; Message parsing+36
parse-eventsub-message+37
eventsub-message-type+38
eventsub-session-from-welcome+39
eventsub-notification-event+40
eventsub-notification-subscription-type+41
+42
;; Subscription management (REST API)+43
twitch-eventsub-subscribe+44
twitch-eventsub-subscriptions+45
twitch-eventsub-delete-subscription+46
+47
;; Common subscription helpers+48
twitch-subscribe-stream-online+49
twitch-subscribe-stream-offline+50
twitch-subscribe-channel-update+51
twitch-subscribe-chat-message+52
twitch-subscribe-follow)+53
+54
(begin+55
+56
;; ---------------------------------------------------------------+57
;; Records+58
;; ---------------------------------------------------------------+59
+60
(define-struct twitch-eventsub-session+61
(id)+62
(status default: "connected")+63
(keepalive-timeout default: 10)+64
(reconnect-url default: #f))+65
+66
;; ---------------------------------------------------------------+67
;; Message parsing+68
;; ---------------------------------------------------------------+69
+70
;;; Parse a raw EventSub WebSocket message (JSON string) into+71
;;; a structured dict.+72
(define (parse-eventsub-message json-str)+73
(json-decode json-str))+74
+75
;;; Get the message type from an EventSub message.+76
;;; Returns: "session_welcome", "notification", "session_keepalive",+77
;;; "session_reconnect", or "revocation".+78
(define (eventsub-message-type msg)+79
(let ((metadata (dict-ref msg metadata: #{})))+80
(dict-ref metadata message_type: #f)))+81
+82
;;; Extract session info from a session_welcome message.+83
;;; Returns a twitch-eventsub-session record.+84
(define (eventsub-session-from-welcome msg)+85
(let* ((payload (dict-ref msg payload: #{}))+86
(session (dict-ref payload session: #{})))+87
(twitch-eventsub-session+88
id: (dict-ref session id:)+89
status: (dict-ref session status: "connected")+90
keepalive-timeout: (dict-ref session keepalive_timeout_seconds: 10)+91
reconnect-url: (let ((url (dict-ref session reconnect_url: #f)))+92
(if (or (not url) (eq? url 'null)) #f url)))))+93
+94
;;; Extract the event data from a notification message.+95
;;; Returns the event dict with type-specific fields.+96
(define (eventsub-notification-event msg)+97
(let ((payload (dict-ref msg payload: #{})))+98
(dict-ref payload event: #{})))+99
+100
;;; Get the subscription type from a notification message.+101
;;; e.g., "stream.online", "channel.chat.message"+102
(define (eventsub-notification-subscription-type msg)+103
(let* ((payload (dict-ref msg payload: #{}))+104
(sub (dict-ref payload subscription: #{})))+105
(dict-ref sub type: #f)))+106
+107
;; ---------------------------------------------------------------+108
;; REST API — Subscription management+109
;; ---------------------------------------------------------------+110
+111
;;; Create an EventSub subscription.+112
;;; type: event type string (e.g., "stream.online")+113
;;; version: API version string (e.g., "1")+114
;;; condition: dict of condition parameters+115
;;; session-id: WebSocket session ID from welcome message+116
(define (twitch-eventsub-subscribe client type version condition session-id)+117
(let* ((body #{ type: type+118
version: version+119
condition: condition+120
transport: #{ method: "websocket"+121
session_id: session-id } })+122
(url (twitch-api-url client "eventsub" "subscriptions")))+123
(twitch-post/json client url body)))+124
+125
;;; List current EventSub subscriptions.+126
;;; Optional opts dict:+127
;;; status: filter by status+128
;;; type: filter by subscription type+129
;;; after: pagination cursor+130
(define (twitch-eventsub-subscriptions client . rest)+131
(let ((opts (if (null? rest) #{} (car rest))))+132
(let* ((params (list+133
(cons "status"+134
(dict-ref opts status: #f))+135
(cons "type"+136
(dict-ref opts type: #f))+137
(cons "after"+138
(dict-ref opts after: #f))))+139
(url (string-append+140
(twitch-api-url client "eventsub" "subscriptions")+141
(build-query-string params))))+142
(twitch-get/json client url))))+143
+144
;;; Delete an EventSub subscription by ID.+145
(define (twitch-eventsub-delete-subscription client subscription-id)+146
(let ((url (string-append+147
(twitch-api-url client "eventsub" "subscriptions")+148
(build-query-string+149
(list (cons "id" subscription-id))))))+150
(twitch-delete/json client url)))+151
+152
;; ---------------------------------------------------------------+153
;; Convenience subscription helpers+154
;; ---------------------------------------------------------------+155
+156
;;; Subscribe to stream.online events.+157
(define (twitch-subscribe-stream-online client session-id broadcaster-id)+158
(twitch-eventsub-subscribe client "stream.online" "1"+159
#{ broadcaster_user_id: broadcaster-id }+160
session-id))+161
+162
;;; Subscribe to stream.offline events.+163
(define (twitch-subscribe-stream-offline client session-id broadcaster-id)+164
(twitch-eventsub-subscribe client "stream.offline" "1"+165
#{ broadcaster_user_id: broadcaster-id }+166
session-id))+167
+168
;;; Subscribe to channel.update events (title/category changes).+169
(define (twitch-subscribe-channel-update client session-id broadcaster-id)+170
(twitch-eventsub-subscribe client "channel.update" "2"+171
#{ broadcaster_user_id: broadcaster-id }+172
session-id))+173
+174
;;; Subscribe to channel.chat.message events.+175
;;; Requires user:read:chat scope.+176
(define (twitch-subscribe-chat-message client session-id broadcaster-id+177
user-id)+178
(twitch-eventsub-subscribe client "channel.chat.message" "1"+179
#{ broadcaster_user_id: broadcaster-id+180
user_id: user-id }+181
session-id))+182
+183
;;; Subscribe to channel.follow events (v2).+184
;;; Requires moderator:read:followers scope.+185
(define (twitch-subscribe-follow client session-id broadcaster-id+186
moderator-id)+187
(twitch-eventsub-subscribe client "channel.follow" "2"+188
#{ broadcaster_user_id: broadcaster-id+189
moderator_user_id: moderator-id }+190
session-id))+191
+192
))src/sigil/twitch/schedule.sgladded
@@ -0,0 +1,225 @@
+1
;;; (sigil twitch schedule) - Twitch stream schedule management.+2
;;;+3
;;; CRUD operations for schedule segments (recurring and non-recurring),+4
;;; vacation settings, and iCalendar export.+5
;;;+6
;;; Requires channel:manage:schedule scope for write operations.+7
;;; Read operations work with any app or user token.+8
+9
(define-library (sigil twitch schedule)+10
(import (sigil core)+11
(sigil dict)+12
(sigil string)+13
(sigil struct)+14
(sigil json)+15
(sigil http client)+16
(sigil twitch))+17
+18
(export ;; Records+19
twitch-schedule-segment+20
twitch-schedule-segment?+21
twitch-schedule-segment-id+22
twitch-schedule-segment-start-time+23
twitch-schedule-segment-end-time+24
twitch-schedule-segment-title+25
twitch-schedule-segment-category+26
twitch-schedule-segment-is-recurring+27
+28
;; Parsing+29
parse-schedule-segment+30
+31
;; API functions+32
twitch-schedule+33
twitch-create-segment+34
twitch-update-segment+35
twitch-delete-segment+36
twitch-schedule-vacation+37
twitch-schedule-ical)+38
+39
(begin+40
+41
;; ---------------------------------------------------------------+42
;; Records+43
;; ---------------------------------------------------------------+44
+45
(define-struct twitch-schedule-segment+46
(id)+47
(start-time default: #f)+48
(end-time default: #f)+49
(title default: "")+50
(category default: #{})+51
(is-recurring default: #f))+52
+53
;; ---------------------------------------------------------------+54
;; Parsing+55
;; ---------------------------------------------------------------+56
+57
;;; Parse a schedule segment from the API response.+58
(define (parse-schedule-segment data)+59
(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)))+67
+68
;; ---------------------------------------------------------------+69
;; API functions+70
;; ---------------------------------------------------------------+71
+72
;;; Get the stream schedule for a broadcaster.+73
;;; Returns a dict with segments: (list of twitch-schedule-segment)+74
;;; and vacation: (dict or #f).+75
;;;+76
;;; Optional opts dict:+77
;;; start-time: ISO 8601 datetime (filter segments on/after)+78
;;; first: number (1-25, default: 20)+79
;;; after: pagination cursor+80
(define (twitch-schedule client broadcaster-id . rest)+81
(let ((opts (if (null? rest) #{} (car rest))))+82
(let* ((params (list+83
(cons "broadcaster_id" broadcaster-id)+84
(cons "start_time"+85
(dict-ref opts start-time: #f))+86
(cons "first"+87
(if (dict-ref opts first: #f)+88
(number->string (dict-ref opts first:))+89
#f))+90
(cons "after"+91
(dict-ref opts after: #f))))+92
(url (string-append+93
(twitch-api-url client "schedule")+94
(build-query-string params)))+95
(response (twitch-get/json client url))+96
(data (dict-ref response data: #{}))+97
(segments (dict-ref data segments: #[]))+98
(vacation (dict-ref data vacation: #f)))+99
#{ segments: (map parse-schedule-segment (array->list segments))+100
vacation: vacation })))+101
+102
;;; Create a new schedule segment.+103
;;; Requires channel:manage:schedule scope.+104
;;;+105
;;; start-time: ISO 8601 datetime+106
;;; timezone: IANA timezone string (e.g., "America/New_York")+107
;;; Optional opts dict:+108
;;; duration: minutes (default: 240)+109
;;; title: segment title+110
;;; category-id: game/category ID+111
;;; is-recurring: boolean (default: #f)+112
(define (twitch-create-segment client broadcaster-id start-time timezone+113
. rest)+114
(let ((opts (if (null? rest) #{} (car rest))))+115
(let* ((body (let* ((b #{ start_time: start-time+116
timezone: timezone+117
is_recurring: (dict-ref opts is-recurring: #f) })+118
(b (if (dict-ref opts duration: #f)+119
(dict-set b duration:+120
(number->string (dict-ref opts duration:)))+121
b))+122
(b (if (dict-ref opts title: #f)+123
(dict-set b title: (dict-ref opts title:))+124
b))+125
(b (if (dict-ref opts category-id: #f)+126
(dict-set b category_id:+127
(dict-ref opts category-id:))+128
b)))+129
b))+130
(url (string-append+131
(twitch-api-url client "schedule" "segment")+132
(build-query-string+133
(list (cons "broadcaster_id" broadcaster-id)))))+134
(response (twitch-post/json client url body))+135
(data (dict-ref response data: #{}))+136
(segments (dict-ref data segments: #[])))+137
(if (> (array-length segments) 0)+138
(parse-schedule-segment (array-ref segments 0))+139
#f))))+140
+141
;;; Update an existing schedule segment.+142
;;; Requires channel:manage:schedule scope.+143
;;;+144
;;; updates dict may contain:+145
;;; start-time: ISO 8601 datetime+146
;;; timezone: IANA timezone string+147
;;; duration: minutes+148
;;; title: segment title+149
;;; category-id: game/category ID+150
;;; is-canceled: boolean (cancel next occurrence of recurring)+151
(define (twitch-update-segment client broadcaster-id segment-id updates)+152
(let* ((body (let* ((b #{})+153
(b (if (dict-ref updates start-time: #f)+154
(dict-set b start_time:+155
(dict-ref updates start-time:))+156
b))+157
(b (if (dict-ref updates timezone: #f)+158
(dict-set b timezone:+159
(dict-ref updates timezone:))+160
b))+161
(b (if (dict-ref updates duration: #f)+162
(dict-set b duration:+163
(number->string (dict-ref updates duration:)))+164
b))+165
(b (if (dict-ref updates title: #f)+166
(dict-set b title: (dict-ref updates title:))+167
b))+168
(b (if (dict-ref updates category-id: #f)+169
(dict-set b category_id:+170
(dict-ref updates category-id:))+171
b))+172
(b (if (dict-ref updates is-canceled: #f)+173
(dict-set b is_canceled:+174
(dict-ref updates is-canceled:))+175
b)))+176
b))+177
(url (string-append+178
(twitch-api-url client "schedule" "segment")+179
(build-query-string+180
(list (cons "broadcaster_id" broadcaster-id)+181
(cons "id" segment-id))))))+182
(twitch-patch/json client url body)))+183
+184
;;; Delete a schedule segment.+185
;;; WARNING: For recurring segments, this deletes the entire series.+186
;;; Requires channel:manage:schedule scope.+187
(define (twitch-delete-segment client broadcaster-id segment-id)+188
(let ((url (string-append+189
(twitch-api-url client "schedule" "segment")+190
(build-query-string+191
(list (cons "broadcaster_id" broadcaster-id)+192
(cons "id" segment-id))))))+193
(twitch-delete/json client url)))+194
+195
;;; Set or clear vacation mode on the schedule.+196
;;; Requires channel:manage:schedule scope.+197
;;;+198
;;; To set vacation: pass enabled: #t with start-time:, end-time:,+199
;;; and timezone:.+200
;;; To clear vacation: pass enabled: #f (other fields ignored).+201
(define (twitch-schedule-vacation client broadcaster-id opts)+202
(let* ((enabled (dict-ref opts enabled: #f))+203
(body (if enabled+204
#{ is_vacation_enabled: #t+205
vacation_start_time: (dict-ref opts start-time:)+206
vacation_end_time: (dict-ref opts end-time:)+207
timezone: (dict-ref opts timezone:) }+208
#{ is_vacation_enabled: #f }))+209
(url (string-append+210
(twitch-api-url client "schedule" "settings")+211
(build-query-string+212
(list (cons "broadcaster_id" broadcaster-id))))))+213
(twitch-put/json client url body)))+214
+215
;;; Get the schedule as iCalendar (.ics) format.+216
;;; Returns the raw iCalendar string.+217
(define (twitch-schedule-ical client broadcaster-id)+218
(let ((url (string-append+219
(twitch-api-url client "schedule" "icalendar")+220
(build-query-string+221
(list (cons "broadcaster_id" broadcaster-id))))))+222
(check-twitch-response/raw+223
(http-get url headers: (twitch-auth-headers client)))))+224
+225
))test/analytics-test.sgladded
@@ -0,0 +1,154 @@
+1
;;; Tests for (sigil twitch analytics) — analytics module+2
;;;+3
;;; Tests record construction and JSON parsing for clips and videos.+4
+5
(import (sigil core)+6
(sigil dict)+7
(sigil string)+8
(sigil struct)+9
(sigil json)+10
(sigil test)+11
(sigil twitch)+12
(sigil twitch analytics))+13
+14
;; ---------------------------------------------------------------+15
;; Test fixtures+16
;; ---------------------------------------------------------------+17
+18
(define clip-resource-json+19
(json-decode+20
(string-append+21
"{\"id\": \"AwkwardHelplessSalamanderSwiftRage\","+22
" \"url\": \"https://clips.twitch.tv/AwkwardHelp\","+23
" \"creator_name\": \"ClipperUser\","+24
" \"video_id\": \"1234567890\","+25
" \"game_id\": \"509670\","+26
" \"title\": \"Amazing moment\","+27
" \"view_count\": 5000,"+28
" \"duration\": 30.5}")))+29
+30
(define clip-minimal-json+31
(json-decode+32
(string-append+33
"{\"id\": \"clip123\"}")))+34
+35
(define video-resource-json+36
(json-decode+37
(string-append+38
"{\"id\": \"335921245\","+39
" \"user_id\": \"141981764\","+40
" \"title\": \"Past Broadcast\","+41
" \"type\": \"archive\","+42
" \"duration\": \"3h21m45s\","+43
" \"view_count\": 8765,"+44
" \"created_at\": \"2026-03-20T14:00:00Z\"}")))+45
+46
(define video-minimal-json+47
(json-decode+48
(string-append+49
"{\"id\": \"vid999\"}")))+50
+51
;; ---------------------------------------------------------------+52
;; Clip record tests+53
;; ---------------------------------------------------------------+54
+55
(test-group "clip records"+56
+57
(test "clip record construction"+58
(let ((c (twitch-clip id: "clip1"+59
url: "https://clips.twitch.tv/test"+60
creator-name: "User1"+61
title: "Cool clip"+62
view-count: 100+63
duration: 25)))+64
(assert-true (twitch-clip? c))+65
(assert-equal "clip1" (twitch-clip-id c))+66
(assert-equal "Cool clip" (twitch-clip-title c))+67
(assert-equal 100 (twitch-clip-view-count c))+68
(assert-equal 25 (twitch-clip-duration c))))+69
+70
(test "clip record defaults"+71
(let ((c (twitch-clip id: "clip2")))+72
(assert-equal "" (twitch-clip-url c))+73
(assert-equal "" (twitch-clip-creator-name c))+74
(assert-equal 0 (twitch-clip-view-count c))+75
(assert-equal 0 (twitch-clip-duration c)))))+76
+77
;; ---------------------------------------------------------------+78
;; Video record tests+79
;; ---------------------------------------------------------------+80
+81
(test-group "video records"+82
+83
(test "video record construction"+84
(let ((v (twitch-video id: "vid1"+85
user-id: "12345"+86
title: "My Stream"+87
type: "archive"+88
duration: "2h30m"+89
view-count: 500)))+90
(assert-true (twitch-video? v))+91
(assert-equal "vid1" (twitch-video-id v))+92
(assert-equal "My Stream" (twitch-video-title v))+93
(assert-equal "archive" (twitch-video-type v))+94
(assert-equal 500 (twitch-video-view-count v))))+95
+96
(test "video record defaults"+97
(let ((v (twitch-video id: "vid2")))+98
(assert-equal "" (twitch-video-user-id v))+99
(assert-equal "" (twitch-video-title v))+100
(assert-equal "" (twitch-video-type v))+101
(assert-equal 0 (twitch-video-view-count v))+102
(assert-equal #f (twitch-video-created-at v)))))+103
+104
;; ---------------------------------------------------------------+105
;; Clip parsing tests+106
;; ---------------------------------------------------------------+107
+108
(test-group "clip parsing"+109
+110
(test "parse full clip resource"+111
(let ((c (parse-clip clip-resource-json)))+112
(assert-true (twitch-clip? c))+113
(assert-equal "AwkwardHelplessSalamanderSwiftRage"+114
(twitch-clip-id c))+115
(assert-equal "https://clips.twitch.tv/AwkwardHelp"+116
(twitch-clip-url c))+117
(assert-equal "ClipperUser" (twitch-clip-creator-name c))+118
(assert-equal "1234567890" (twitch-clip-video-id c))+119
(assert-equal "509670" (twitch-clip-game-id c))+120
(assert-equal "Amazing moment" (twitch-clip-title c))+121
(assert-equal 5000 (twitch-clip-view-count c))))+122
+123
(test "parse minimal clip resource"+124
(let ((c (parse-clip clip-minimal-json)))+125
(assert-equal "clip123" (twitch-clip-id c))+126
(assert-equal "" (twitch-clip-url c))+127
(assert-equal 0 (twitch-clip-view-count c)))))+128
+129
;; ---------------------------------------------------------------+130
;; Video parsing tests+131
;; ---------------------------------------------------------------+132
+133
(test-group "video parsing"+134
+135
(test "parse full video resource"+136
(let ((v (parse-video video-resource-json)))+137
(assert-true (twitch-video? v))+138
(assert-equal "335921245" (twitch-video-id v))+139
(assert-equal "141981764" (twitch-video-user-id v))+140
(assert-equal "Past Broadcast" (twitch-video-title v))+141
(assert-equal "archive" (twitch-video-type v))+142
(assert-equal "3h21m45s" (twitch-video-duration v))+143
(assert-equal 8765 (twitch-video-view-count v))+144
(assert-equal "2026-03-20T14:00:00Z"+145
(twitch-video-created-at v))))+146
+147
(test "parse minimal video resource"+148
(let ((v (parse-video video-minimal-json)))+149
(assert-equal "vid999" (twitch-video-id v))+150
(assert-equal "" (twitch-video-user-id v))+151
(assert-equal "" (twitch-video-title v))+152
(assert-equal 0 (twitch-video-view-count v)))))+153
+154
(run-tests)test/eventsub-test.sgladded
@@ -0,0 +1,177 @@
+1
;;; Tests for (sigil twitch eventsub) — EventSub module+2
;;;+3
;;; Tests message parsing, session extraction, and notification handling+4
;;; using fixture data (no real WebSocket connection).+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 eventsub))+14
+15
;; ---------------------------------------------------------------+16
;; Test fixtures — WebSocket message samples+17
;; ---------------------------------------------------------------+18
+19
(define welcome-message-json+20
(string-append+21
"{\"metadata\": {"+22
" \"message_id\": \"96a3f3b5-5dec-4eed-908e-e11ee657416c\","+23
" \"message_type\": \"session_welcome\","+24
" \"message_timestamp\": \"2026-03-25T14:00:00Z\""+25
" },"+26
" \"payload\": {"+27
" \"session\": {"+28
" \"id\": \"AQoQILE98fqzi_WP7l4b-38N4A\","+29
" \"status\": \"connected\","+30
" \"keepalive_timeout_seconds\": 30,"+31
" \"reconnect_url\": null"+32
" }"+33
" }}"))+34
+35
(define notification-message-json+36
(string-append+37
"{\"metadata\": {"+38
" \"message_id\": \"befa7b53-d79d-478f-86b9-120f112b044e\","+39
" \"message_type\": \"notification\","+40
" \"message_timestamp\": \"2026-03-25T14:05:00Z\","+41
" \"subscription_type\": \"stream.online\","+42
" \"subscription_version\": \"1\""+43
" },"+44
" \"payload\": {"+45
" \"subscription\": {"+46
" \"id\": \"f1c2a387-161a-49f9-a165-0f21d7a4e1c4\","+47
" \"type\": \"stream.online\","+48
" \"version\": \"1\""+49
" },"+50
" \"event\": {"+51
" \"id\": \"9001\","+52
" \"broadcaster_user_id\": \"141981764\","+53
" \"broadcaster_user_login\": \"twitchdev\","+54
" \"broadcaster_user_name\": \"TwitchDev\","+55
" \"type\": \"live\","+56
" \"started_at\": \"2026-03-25T14:05:00Z\""+57
" }"+58
" }}"))+59
+60
(define keepalive-message-json+61
(string-append+62
"{\"metadata\": {"+63
" \"message_id\": \"keepalive-1\","+64
" \"message_type\": \"session_keepalive\","+65
" \"message_timestamp\": \"2026-03-25T14:01:00Z\""+66
" },"+67
" \"payload\": {}}"))+68
+69
(define reconnect-message-json+70
(string-append+71
"{\"metadata\": {"+72
" \"message_id\": \"reconnect-1\","+73
" \"message_type\": \"session_reconnect\","+74
" \"message_timestamp\": \"2026-03-25T14:10:00Z\""+75
" },"+76
" \"payload\": {"+77
" \"session\": {"+78
" \"id\": \"AQoQILE98fqzi_WP7l4b-38N4A\","+79
" \"status\": \"reconnecting\","+80
" \"keepalive_timeout_seconds\": 30,"+81
" \"reconnect_url\": \"wss://eventsub.wss.twitch.tv/ws?token=abc\""+82
" }"+83
" }}"))+84
+85
;; ---------------------------------------------------------------+86
;; Session record tests+87
;; ---------------------------------------------------------------+88
+89
(test-group "eventsub session records"+90
+91
(test "session record construction"+92
(let ((s (twitch-eventsub-session+93
id: "session1"+94
status: "connected"+95
keepalive-timeout: 30)))+96
(assert-true (twitch-eventsub-session? s))+97
(assert-equal "session1" (twitch-eventsub-session-id s))+98
(assert-equal "connected" (twitch-eventsub-session-status s))+99
(assert-equal 30 (twitch-eventsub-session-keepalive-timeout s))+100
(assert-equal #f (twitch-eventsub-session-reconnect-url s))))+101
+102
(test "session record defaults"+103
(let ((s (twitch-eventsub-session id: "s2")))+104
(assert-equal "connected" (twitch-eventsub-session-status s))+105
(assert-equal 10 (twitch-eventsub-session-keepalive-timeout s)))))+106
+107
;; ---------------------------------------------------------------+108
;; Message type detection tests+109
;; ---------------------------------------------------------------+110
+111
(test-group "message type detection"+112
+113
(test "welcome message type"+114
(let ((msg (parse-eventsub-message welcome-message-json)))+115
(assert-equal "session_welcome" (eventsub-message-type msg))))+116
+117
(test "notification message type"+118
(let ((msg (parse-eventsub-message notification-message-json)))+119
(assert-equal "notification" (eventsub-message-type msg))))+120
+121
(test "keepalive message type"+122
(let ((msg (parse-eventsub-message keepalive-message-json)))+123
(assert-equal "session_keepalive" (eventsub-message-type msg))))+124
+125
(test "reconnect message type"+126
(let ((msg (parse-eventsub-message reconnect-message-json)))+127
(assert-equal "session_reconnect" (eventsub-message-type msg)))))+128
+129
;; ---------------------------------------------------------------+130
;; Session extraction tests+131
;; ---------------------------------------------------------------+132
+133
(test-group "session extraction from welcome"+134
+135
(test "extract session from welcome message"+136
(let* ((msg (parse-eventsub-message welcome-message-json))+137
(session (eventsub-session-from-welcome msg)))+138
(assert-true (twitch-eventsub-session? session))+139
(assert-equal "AQoQILE98fqzi_WP7l4b-38N4A"+140
(twitch-eventsub-session-id session))+141
(assert-equal "connected"+142
(twitch-eventsub-session-status session))+143
(assert-equal 30+144
(twitch-eventsub-session-keepalive-timeout session))+145
(assert-equal #f+146
(twitch-eventsub-session-reconnect-url session))))+147
+148
(test "extract session from reconnect message"+149
(let* ((msg (parse-eventsub-message reconnect-message-json))+150
(session (eventsub-session-from-welcome msg)))+151
(assert-equal "reconnecting"+152
(twitch-eventsub-session-status session))+153
(assert-equal "wss://eventsub.wss.twitch.tv/ws?token=abc"+154
(twitch-eventsub-session-reconnect-url session)))))+155
+156
;; ---------------------------------------------------------------+157
;; Notification extraction tests+158
;; ---------------------------------------------------------------+159
+160
(test-group "notification extraction"+161
+162
(test "extract event from notification"+163
(let* ((msg (parse-eventsub-message notification-message-json))+164
(event (eventsub-notification-event msg)))+165
(assert-true (dict? event))+166
(assert-equal "141981764"+167
(dict-ref event broadcaster_user_id:))+168
(assert-equal "live" (dict-ref event type:))+169
(assert-equal "2026-03-25T14:05:00Z"+170
(dict-ref event started_at:))))+171
+172
(test "extract subscription type from notification"+173
(let ((msg (parse-eventsub-message notification-message-json)))+174
(assert-equal "stream.online"+175
(eventsub-notification-subscription-type msg)))))+176
+177
(run-tests)test/schedule-test.sgladded
@@ -0,0 +1,94 @@
+1
;;; Tests for (sigil twitch schedule) — schedule module+2
;;;+3
;;; Tests record construction and JSON parsing for schedule segments.+4
+5
(import (sigil core)+6
(sigil dict)+7
(sigil string)+8
(sigil struct)+9
(sigil json)+10
(sigil test)+11
(sigil twitch)+12
(sigil twitch schedule))+13
+14
;; ---------------------------------------------------------------+15
;; Test fixtures+16
;; ---------------------------------------------------------------+17
+18
(define segment-resource-json+19
(json-decode+20
(string-append+21
"{\"id\": \"eyJhbGciOiJIUzI1NiJ9\","+22
" \"start_time\": \"2026-03-28T18:00:00Z\","+23
" \"end_time\": \"2026-03-28T22:00:00Z\","+24
" \"title\": \"Friday Coding Stream\","+25
" \"category\": {\"id\": \"509670\","+26
" \"name\": \"Science & Technology\"},"+27
" \"is_recurring\": true}")))+28
+29
(define segment-minimal-json+30
(json-decode+31
(string-append+32
"{\"id\": \"abc123\","+33
" \"start_time\": \"2026-04-01T20:00:00Z\"}")))+34
+35
;; ---------------------------------------------------------------+36
;; Schedule segment record tests+37
;; ---------------------------------------------------------------+38
+39
(test-group "schedule segment records"+40
+41
(test "segment record construction"+42
(let ((seg (twitch-schedule-segment+43
id: "seg1"+44
start-time: "2026-03-28T18:00:00Z"+45
end-time: "2026-03-28T22:00:00Z"+46
title: "Stream"+47
is-recurring: #t)))+48
(assert-true (twitch-schedule-segment? seg))+49
(assert-equal "seg1" (twitch-schedule-segment-id seg))+50
(assert-equal "2026-03-28T18:00:00Z"+51
(twitch-schedule-segment-start-time seg))+52
(assert-equal "Stream" (twitch-schedule-segment-title seg))+53
(assert-equal #t (twitch-schedule-segment-is-recurring seg))))+54
+55
(test "segment record defaults"+56
(let ((seg (twitch-schedule-segment id: "seg2")))+57
(assert-equal #f (twitch-schedule-segment-start-time seg))+58
(assert-equal #f (twitch-schedule-segment-end-time seg))+59
(assert-equal "" (twitch-schedule-segment-title seg))+60
(assert-equal #f (twitch-schedule-segment-is-recurring seg)))))+61
+62
;; ---------------------------------------------------------------+63
;; Schedule segment parsing tests+64
;; ---------------------------------------------------------------+65
+66
(test-group "schedule segment parsing"+67
+68
(test "parse full segment resource"+69
(let ((seg (parse-schedule-segment segment-resource-json)))+70
(assert-true (twitch-schedule-segment? seg))+71
(assert-equal "eyJhbGciOiJIUzI1NiJ9"+72
(twitch-schedule-segment-id seg))+73
(assert-equal "2026-03-28T18:00:00Z"+74
(twitch-schedule-segment-start-time seg))+75
(assert-equal "2026-03-28T22:00:00Z"+76
(twitch-schedule-segment-end-time seg))+77
(assert-equal "Friday Coding Stream"+78
(twitch-schedule-segment-title seg))+79
(assert-equal #t (twitch-schedule-segment-is-recurring seg))+80
(let ((cat (twitch-schedule-segment-category seg)))+81
(assert-true (dict? cat))+82
(assert-equal "509670" (dict-ref cat id:))+83
(assert-equal "Science & Technology" (dict-ref cat name:)))))+84
+85
(test "parse minimal segment resource"+86
(let ((seg (parse-schedule-segment segment-minimal-json)))+87
(assert-equal "abc123" (twitch-schedule-segment-id seg))+88
(assert-equal "2026-04-01T20:00:00Z"+89
(twitch-schedule-segment-start-time seg))+90
(assert-equal #f (twitch-schedule-segment-end-time seg))+91
(assert-equal "" (twitch-schedule-segment-title seg))+92
(assert-equal #f (twitch-schedule-segment-is-recurring seg)))))+93
+94
(run-tests)test/twitch-test.sgladded
@@ -0,0 +1,324 @@
+1
;;; Tests for (sigil twitch) — core module+2
;;;+3
;;; Tests record construction, JSON response parsing, URL building,+4
;;; and query string encoding using fixture data (no real API calls).+5
+6
(import (sigil core)+7
(sigil dict)+8
(sigil string)+9
(sigil struct)+10
(sigil json)+11
(sigil test)+12
(sigil twitch))+13
+14
;; ---------------------------------------------------------------+15
;; Test fixtures — JSON response samples+16
;; ---------------------------------------------------------------+17
+18
(define channel-resource-json+19
(json-decode+20
(string-append+21
"{\"broadcaster_id\": \"141981764\","+22
" \"broadcaster_login\": \"twitchdev\","+23
" \"broadcaster_name\": \"TwitchDev\","+24
" \"game_name\": \"Science & Technology\","+25
" \"game_id\": \"509670\","+26
" \"title\": \"Building cool stuff\","+27
" \"tags\": [\"English\", \"Coding\"]}")))+28
+29
(define channel-minimal-json+30
(json-decode+31
(string-append+32
"{\"broadcaster_id\": \"12345\","+33
" \"broadcaster_name\": \"MinimalUser\"}")))+34
+35
(define stream-resource-json+36
(json-decode+37
(string-append+38
"{\"id\": \"40944942733\","+39
" \"user_id\": \"141981764\","+40
" \"user_name\": \"TwitchDev\","+41
" \"game_name\": \"Science & Technology\","+42
" \"title\": \"Live coding session\","+43
" \"viewer_count\": 1234,"+44
" \"started_at\": \"2026-03-25T14:00:00Z\"}")))+45
+46
(define stream-minimal-json+47
(json-decode+48
(string-append+49
"{\"id\": \"99999\","+50
" \"user_id\": \"11111\"}")))+51
+52
(define user-resource-json+53
(json-decode+54
(string-append+55
"{\"id\": \"141981764\","+56
" \"login\": \"twitchdev\","+57
" \"display_name\": \"TwitchDev\","+58
" \"type\": \"\","+59
" \"broadcaster_type\": \"partner\","+60
" \"description\": \"Supporting devs\","+61
" \"profile_image_url\": \"https://example.com/pic.png\","+62
" \"created_at\": \"2016-12-14T20:32:28Z\"}")))+63
+64
(define user-minimal-json+65
(json-decode+66
(string-append+67
"{\"id\": \"54321\","+68
" \"login\": \"testuser\"}")))+69
+70
;; ---------------------------------------------------------------+71
;; Client construction tests+72
;; ---------------------------------------------------------------+73
+74
(test-group "client construction"+75
+76
(test "client with credentials"+77
(let ((c (twitch-client client-id: "abc123"+78
access-token: "oauth-token")))+79
(assert-true (twitch-client? c))+80
(assert-equal "abc123" (twitch-client-client-id c))+81
(assert-equal "oauth-token" (twitch-client-access-token c))+82
(assert-equal "https://api.twitch.tv/helix"+83
(twitch-client-base-url c))))+84
+85
(test "custom base url"+86
(let ((c (twitch-client client-id: "id"+87
access-token: "tok"+88
base-url: "http://localhost:8080/mock")))+89
(assert-equal "http://localhost:8080/mock"+90
(twitch-client-base-url c)))))+91
+92
;; ---------------------------------------------------------------+93
;; Auth headers tests+94
;; ---------------------------------------------------------------+95
+96
(test-group "auth headers"+97
+98
(test "headers include both client-id and bearer token"+99
(let* ((c (twitch-client client-id: "my-client-id"+100
access-token: "my-token"))+101
(headers (twitch-auth-headers c)))+102
(assert-equal "Bearer my-token"+103
(dict-ref headers authorization:))+104
(assert-equal "my-client-id"+105
(dict-ref headers client-id:)))))+106
+107
;; ---------------------------------------------------------------+108
;; Channel record tests+109
;; ---------------------------------------------------------------+110
+111
(test-group "channel records"+112
+113
(test "channel record construction"+114
(let ((ch (twitch-channel id: "12345" name: "TestChannel"+115
game-name: "Just Chatting"+116
game-id: "509658"+117
title: "Hello!"+118
tags: '("English" "Coding"))))+119
(assert-true (twitch-channel? ch))+120
(assert-equal "12345" (twitch-channel-id ch))+121
(assert-equal "TestChannel" (twitch-channel-name ch))+122
(assert-equal "Just Chatting" (twitch-channel-game-name ch))+123
(assert-equal "509658" (twitch-channel-game-id ch))+124
(assert-equal "Hello!" (twitch-channel-title ch))+125
(assert-equal '("English" "Coding") (twitch-channel-tags ch))))+126
+127
(test "channel record defaults"+128
(let ((ch (twitch-channel id: "99")))+129
(assert-equal "" (twitch-channel-name ch))+130
(assert-equal "" (twitch-channel-game-name ch))+131
(assert-equal "" (twitch-channel-game-id ch))+132
(assert-equal "" (twitch-channel-title ch))+133
(assert-equal '() (twitch-channel-tags ch)))))+134
+135
;; ---------------------------------------------------------------+136
;; Stream record tests+137
;; ---------------------------------------------------------------+138
+139
(test-group "stream records"+140
+141
(test "stream record construction"+142
(let ((s (twitch-stream id: "40944942733"+143
user-id: "141981764"+144
user-name: "TwitchDev"+145
game-name: "Science & Technology"+146
title: "Live coding"+147
viewer-count: 500+148
started-at: "2026-03-25T14:00:00Z")))+149
(assert-true (twitch-stream? s))+150
(assert-equal "40944942733" (twitch-stream-id s))+151
(assert-equal "141981764" (twitch-stream-user-id s))+152
(assert-equal 500 (twitch-stream-viewer-count s))))+153
+154
(test "stream record defaults"+155
(let ((s (twitch-stream id: "1")))+156
(assert-equal "" (twitch-stream-user-id s))+157
(assert-equal 0 (twitch-stream-viewer-count s))+158
(assert-equal #f (twitch-stream-started-at s)))))+159
+160
;; ---------------------------------------------------------------+161
;; User record tests+162
;; ---------------------------------------------------------------+163
+164
(test-group "user records"+165
+166
(test "user record construction"+167
(let ((u (twitch-user id: "141981764"+168
login: "twitchdev"+169
display-name: "TwitchDev"+170
broadcaster-type: "partner")))+171
(assert-true (twitch-user? u))+172
(assert-equal "141981764" (twitch-user-id u))+173
(assert-equal "twitchdev" (twitch-user-login u))+174
(assert-equal "partner" (twitch-user-broadcaster-type u))))+175
+176
(test "user record defaults"+177
(let ((u (twitch-user id: "1")))+178
(assert-equal "" (twitch-user-login u))+179
(assert-equal "" (twitch-user-display-name u))+180
(assert-equal "" (twitch-user-type u))+181
(assert-equal #f (twitch-user-created-at u)))))+182
+183
;; ---------------------------------------------------------------+184
;; Channel parsing tests+185
;; ---------------------------------------------------------------+186
+187
(test-group "channel parsing"+188
+189
(test "parse full channel resource"+190
(let ((ch (parse-channel channel-resource-json)))+191
(assert-true (twitch-channel? ch))+192
(assert-equal "141981764" (twitch-channel-id ch))+193
(assert-equal "TwitchDev" (twitch-channel-name ch))+194
(assert-equal "Science & Technology" (twitch-channel-game-name ch))+195
(assert-equal "509670" (twitch-channel-game-id ch))+196
(assert-equal "Building cool stuff" (twitch-channel-title ch))+197
(assert-equal '("English" "Coding") (twitch-channel-tags ch))))+198
+199
(test "parse minimal channel resource"+200
(let ((ch (parse-channel channel-minimal-json)))+201
(assert-equal "12345" (twitch-channel-id ch))+202
(assert-equal "MinimalUser" (twitch-channel-name ch))+203
(assert-equal "" (twitch-channel-game-name ch))+204
(assert-equal '() (twitch-channel-tags ch)))))+205
+206
;; ---------------------------------------------------------------+207
;; Stream parsing tests+208
;; ---------------------------------------------------------------+209
+210
(test-group "stream parsing"+211
+212
(test "parse full stream resource"+213
(let ((s (parse-stream stream-resource-json)))+214
(assert-true (twitch-stream? s))+215
(assert-equal "40944942733" (twitch-stream-id s))+216
(assert-equal "141981764" (twitch-stream-user-id s))+217
(assert-equal "TwitchDev" (twitch-stream-user-name s))+218
(assert-equal "Science & Technology" (twitch-stream-game-name s))+219
(assert-equal "Live coding session" (twitch-stream-title s))+220
(assert-equal 1234 (twitch-stream-viewer-count s))+221
(assert-equal "2026-03-25T14:00:00Z" (twitch-stream-started-at s))))+222
+223
(test "parse minimal stream resource"+224
(let ((s (parse-stream stream-minimal-json)))+225
(assert-equal "99999" (twitch-stream-id s))+226
(assert-equal "11111" (twitch-stream-user-id s))+227
(assert-equal "" (twitch-stream-user-name s))+228
(assert-equal 0 (twitch-stream-viewer-count s)))))+229
+230
;; ---------------------------------------------------------------+231
;; User parsing tests+232
;; ---------------------------------------------------------------+233
+234
(test-group "user parsing"+235
+236
(test "parse full user resource"+237
(let ((u (parse-user user-resource-json)))+238
(assert-true (twitch-user? u))+239
(assert-equal "141981764" (twitch-user-id u))+240
(assert-equal "twitchdev" (twitch-user-login u))+241
(assert-equal "TwitchDev" (twitch-user-display-name u))+242
(assert-equal "partner" (twitch-user-broadcaster-type u))+243
(assert-equal "Supporting devs" (twitch-user-description u))+244
(assert-equal "2016-12-14T20:32:28Z" (twitch-user-created-at u))))+245
+246
(test "parse minimal user resource"+247
(let ((u (parse-user user-minimal-json)))+248
(assert-equal "54321" (twitch-user-id u))+249
(assert-equal "testuser" (twitch-user-login u))+250
(assert-equal "" (twitch-user-display-name u))+251
(assert-equal #f (twitch-user-created-at u)))))+252
+253
;; ---------------------------------------------------------------+254
;; URL encoding tests+255
;; ---------------------------------------------------------------+256
+257
(test-group "URL encoding"+258
+259
(test "encode simple string"+260
(assert-equal "hello" (url-encode-value "hello")))+261
+262
(test "encode spaces"+263
(assert-equal "hello+world" (url-encode-value "hello world")))+264
+265
(test "encode special characters"+266
(assert-equal "foo%26bar" (url-encode-value "foo&bar")))+267
+268
(test "encode equals sign"+269
(assert-equal "key%3dvalue" (url-encode-value "key=value")))+270
+271
(test "preserve unreserved characters"+272
(assert-equal "a-b_c.d~e" (url-encode-value "a-b_c.d~e"))))+273
+274
;; ---------------------------------------------------------------+275
;; Query string building tests+276
;; ---------------------------------------------------------------+277
+278
(test-group "query string building"+279
+280
(test "build simple query string"+281
(assert-equal "?broadcaster_id=12345"+282
(build-query-string+283
(list (cons "broadcaster_id" "12345")))))+284
+285
(test "build multi-param query string"+286
(assert-equal "?part=snippet&id=abc123"+287
(build-query-string+288
(list (cons "part" "snippet")+289
(cons "id" "abc123")))))+290
+291
(test "omit false values"+292
(assert-equal "?broadcaster_id=12345"+293
(build-query-string+294
(list (cons "broadcaster_id" "12345")+295
(cons "after" #f)))))+296
+297
(test "empty params"+298
(assert-equal ""+299
(build-query-string '())))+300
+301
(test "encode values in query string"+302
(assert-equal "?q=hello+world"+303
(build-query-string+304
(list (cons "q" "hello world"))))))+305
+306
;; ---------------------------------------------------------------+307
;; Pagination parsing tests+308
;; ---------------------------------------------------------------+309
+310
(test-group "pagination parsing"+311
+312
(test "parse pagination with cursor"+313
(let ((data #{ pagination: #{ cursor: "eyJiI123" } }))+314
(assert-equal "eyJiI123" (parse-pagination data))))+315
+316
(test "parse pagination without cursor"+317
(let ((data #{ pagination: #{} }))+318
(assert-equal #f (parse-pagination data))))+319
+320
(test "parse pagination with missing pagination key"+321
(let ((data #{}))+322
(assert-equal #f (parse-pagination data)))))+323
+324
(run-tests)