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.json
README.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-log
src/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)