Commit872bb4adRecorded20 Mar 2026Repositorysigil-discourse
Initial sigil-discourse library with Discourse API client modules
Message
Provides modules for topics, posts, messages, search, categories, users, groups, and admin operations against the Discourse REST API.
Changed
README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
dev-redirects.sgl | 5 +++++
package.sgl | 27 +++++++++++++++++++++++++++
src/discourse/admin.sgl | 37 +++++++++++++++++++++++++++++++++++++
src/discourse/categories.sgl | 42 ++++++++++++++++++++++++++++++++++++++++++
src/discourse/client.sgl | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/discourse/groups.sgl | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/discourse/messages.sgl | 37 +++++++++++++++++++++++++++++++++++++
src/discourse/posts.sgl | 41 +++++++++++++++++++++++++++++++++++++++++
src/discourse/search.sgl | 23 +++++++++++++++++++++++
src/discourse/topics.sgl | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/discourse/users.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
test/test-client.sgl | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 612 insertions(+)Diff
README.mdadded
@@ -0,0 +1,61 @@
+1
# sigil-discourse+2
+3
A [Sigil](https://codeberg.org/sigil/sigil) library for the Discourse forum REST API.+4
+5
## Modules+6
+7
| Module | Description |+8
|--------|-------------|+9
| `(discourse client)` | Shared auth headers, URL construction, HTTP helpers |+10
| `(discourse topics)` | List, get, create, update, close/open/pin, delete topics |+11
| `(discourse posts)` | Get, create, update, delete posts |+12
| `(discourse messages)` | Send, list private messages |+13
| `(discourse search)` | Full-text search |+14
| `(discourse categories)` | List, get, create, update categories |+15
| `(discourse users)` | List, get, create, suspend/unsuspend, activate/deactivate users |+16
| `(discourse groups)` | List, get, create, delete groups and manage members |+17
| `(discourse admin)` | Site settings and invites |+18
+19
## Usage+20
+21
Add `sigil-discourse` as a dependency in your `package.sgl`:+22
+23
```scheme+24
(from-git url: "codeberg:sigil/sigil-discourse" package: "sigil-discourse")+25
```+26
+27
Then import the modules you need:+28
+29
```scheme+30
(import (discourse topics)+31
(discourse users))+32
+33
(define base-url "https://forum.example.com")+34
(define api-key (getenv "DISCOURSE_API_KEY"))+35
(define username (getenv "DISCOURSE_USERNAME"))+36
+37
;; List latest topics+38
(discourse-list-latest-topics base-url api-key username)+39
+40
;; Create a new topic+41
(discourse-create-topic base-url api-key username+42
"Hello World" "This is the body" 5)+43
+44
;; Get a user+45
(discourse-get-user base-url api-key username "david")+46
```+47
+48
## Authentication+49
+50
Discourse uses `Api-Key` and `Api-Username` headers. Every library function+51
takes `(base-url api-key username ...)` as its first three parameters.+52
+53
## Building+54
+55
```bash+56
sigil build --redirects dev-redirects.sgl+57
```+58
+59
## License+60
+61
BSD-3-Clausedev-redirects.sgladded
@@ -0,0 +1,5 @@
+1
(redirects+2
repos: (list+3
(for-repo+4
url: "codeberg:sigil/sigil"+5
use: (from-path dir: "../sigil"))))package.sgladded
@@ -0,0 +1,27 @@
+1
;;; sigil-discourse - Discourse forum API client library+2
;;;+3
;;; Provides access to the Discourse REST API for managing topics, posts,+4
;;; categories, users, groups, private messages, and admin settings.+5
+6
(define sigil-repo "codeberg:sigil/sigil")+7
+8
(package+9
name: "sigil-discourse"+10
version: "0.1.0"+11
description: "Discourse forum API client library"+12
url: "https://codeberg.org/sigil/sigil-discourse"+13
license: "BSD-3-Clause"+14
authors: (list "David Wilson <[email protected]>")+15
+16
dependencies: (list+17
(from-git url: sigil-repo package: "sigil-stdlib")+18
(from-git url: sigil-repo package: "sigil-http")+19
(from-git url: sigil-repo package: "sigil-tls"))+20
+21
tasks: (list+22
(task+23
name: 'build+24
description: "Compile sigil-discourse modules"+25
steps: (list+26
(compile-sigil-modules sources: "src/**/*.sgl"+27
output-dir: (config-output-subdir "lib"))))))src/discourse/admin.sgladded
@@ -0,0 +1,37 @@
+1
;;; (discourse admin) - Administrative functions.+2
;;;+3
;;; Functions for managing site settings and invites.+4
+5
(define-library (discourse admin)+6
(import (sigil core)+7
(sigil string)+8
(sigil json)+9
(discourse client))+10
+11
(export discourse-list-site-settings+12
discourse-update-site-setting+13
discourse-create-invite)+14
+15
(begin+16
+17
;;; List all site settings (admin endpoint).+18
(define (discourse-list-site-settings base-url api-key username)+19
(discourse-get/json api-key username+20
(discourse-url base-url "admin" "site_settings.json")))+21
+22
;;; Update a site setting (admin endpoint).+23
(define (discourse-update-site-setting base-url api-key username setting-name value)+24
(discourse-put/json api-key username+25
(discourse-url base-url "admin" "site_settings" (string-append setting-name ".json"))+26
#{ setting_name: value }))+27
+28
;;; Create an invite.+29
;;;+30
;;; group-ids is a comma-separated string of group IDs, or "" for none.+31
(define (discourse-create-invite base-url api-key username email group-ids)+32
(discourse-post/json api-key username+33
(discourse-url base-url "invites.json")+34
#{ email: email+35
group_ids: group-ids }))+36
+37
))src/discourse/categories.sgladded
@@ -0,0 +1,42 @@
+1
;;; (discourse categories) - Category management.+2
;;;+3
;;; Functions for listing, creating, and updating Discourse categories.+4
+5
(define-library (discourse categories)+6
(import (sigil core)+7
(sigil string)+8
(discourse client))+9
+10
(export discourse-list-categories+11
discourse-get-category+12
discourse-create-category+13
discourse-update-category)+14
+15
(begin+16
+17
;;; List all categories.+18
(define (discourse-list-categories base-url api-key username)+19
(discourse-get/json api-key username+20
(discourse-url base-url "categories.json")))+21
+22
;;; Get a specific category by ID.+23
(define (discourse-get-category base-url api-key username category-id)+24
(discourse-get/json api-key username+25
(discourse-url base-url "c" (number->string category-id) "show.json")))+26
+27
;;; Create a new category.+28
(define (discourse-create-category base-url api-key username name color text-color)+29
(discourse-post/json api-key username+30
(discourse-url base-url "categories.json")+31
#{ name: name+32
color: color+33
text_color: text-color }))+34
+35
;;; Update an existing category.+36
(define (discourse-update-category base-url api-key username category-id name color)+37
(discourse-put/json api-key username+38
(discourse-url base-url "categories" (string-append (number->string category-id) ".json"))+39
#{ name: name+40
color: color }))+41
+42
))src/discourse/client.sgladded
@@ -0,0 +1,80 @@
+1
;;; (discourse client) - Shared Discourse API client utilities.+2
;;;+3
;;; Provides authentication headers and URL construction used by all+4
;;; other discourse modules.+5
+6
(define-library (discourse client)+7
(import (sigil core)+8
(sigil dict)+9
(sigil string)+10
(sigil json)+11
(sigil http client))+12
+13
(export discourse-auth-headers+14
discourse-url+15
discourse-get/json+16
discourse-post/json+17
discourse-put/json+18
discourse-delete)+19
+20
(begin+21
+22
;;; Build authentication headers for the Discourse API.+23
;;;+24
;;; Discourse uses Api-Key and Api-Username headers rather than+25
;;; a bearer token.+26
(define (discourse-auth-headers api-key username)+27
#{ Api-Key: api-key+28
Api-Username: username })+29
+30
;;; Build an API URL from a base URL and path segments.+31
;;;+32
;;; Unlike Forgejo, Discourse has no /api/v1 prefix.+33
;;; (discourse-url "https://forum.example.com" "latest.json")+34
;;; => "https://forum.example.com/latest.json"+35
(define (discourse-url base-url . parts)+36
(apply string-append base-url+37
(map (lambda (p) (string-append "/" p)) parts)))+38
+39
;; JSON headers for requests with a body.+40
(define (discourse-json-headers api-key username)+41
(dict-merge (discourse-auth-headers api-key username)+42
#{ content-type: "application/json" }))+43
+44
;; Check an HTTP response and return parsed JSON, or raise an error+45
;; with the status code and response body.+46
(define (check-response response)+47
(if (not (http-response? response))+48
(error "Discourse API request failed: no response"))+49
(let ((status (http-response-status response))+50
(body (http-response-body response)))+51
(if (>= status 400)+52
(error (string-append "Discourse API error " (number->string status)+53
": " (or body "")))+54
(if (and body (not (string=? body "")))+55
(json-decode body)+56
#t))))+57
+58
;;; Authenticated JSON GET request.+59
(define (discourse-get/json api-key username url)+60
(check-response+61
(http-get url headers: (discourse-auth-headers api-key username))))+62
+63
;;; Authenticated JSON POST request.+64
(define (discourse-post/json api-key username url body)+65
(check-response+66
(http-post url (json-encode body)+67
headers: (discourse-json-headers api-key username))))+68
+69
;;; Authenticated JSON PUT request.+70
(define (discourse-put/json api-key username url body)+71
(check-response+72
(http-put url (json-encode body)+73
headers: (discourse-json-headers api-key username))))+74
+75
;;; Authenticated DELETE request.+76
(define (discourse-delete api-key username url)+77
(check-response+78
(http-delete url headers: (discourse-auth-headers api-key username))))+79
+80
))src/discourse/groups.sgladded
@@ -0,0 +1,62 @@
+1
;;; (discourse groups) - Group management.+2
;;;+3
;;; Functions for listing, creating, and managing Discourse groups+4
;;; and their members.+5
+6
(define-library (discourse groups)+7
(import (sigil core)+8
(sigil string)+9
(discourse client))+10
+11
(export discourse-list-groups+12
discourse-get-group+13
discourse-create-group+14
discourse-delete-group+15
discourse-add-group-members+16
discourse-remove-group-members+17
discourse-list-group-members)+18
+19
(begin+20
+21
;;; List all groups.+22
(define (discourse-list-groups base-url api-key username)+23
(discourse-get/json api-key username+24
(discourse-url base-url "groups.json")))+25
+26
;;; Get a group by name.+27
(define (discourse-get-group base-url api-key username group-name)+28
(discourse-get/json api-key username+29
(discourse-url base-url "groups" (string-append group-name ".json"))))+30
+31
;;; Create a new group (admin endpoint).+32
(define (discourse-create-group base-url api-key username group-name)+33
(discourse-post/json api-key username+34
(discourse-url base-url "admin" "groups.json")+35
#{ group: #{ name: group-name } }))+36
+37
;;; Delete a group (admin endpoint).+38
(define (discourse-delete-group base-url api-key username group-id)+39
(discourse-delete api-key username+40
(discourse-url base-url "admin" "groups" (string-append (number->string group-id) ".json"))))+41
+42
;;; Add members to a group.+43
;;;+44
;;; usernames is a comma-separated string.+45
(define (discourse-add-group-members base-url api-key username group-id usernames)+46
(discourse-put/json api-key username+47
(discourse-url base-url "groups" (number->string group-id) "members.json")+48
#{ usernames: usernames }))+49
+50
;;; Remove members from a group.+51
;;;+52
;;; usernames is a comma-separated string.+53
(define (discourse-remove-group-members base-url api-key username group-id usernames)+54
(discourse-delete api-key username+55
(discourse-url base-url "groups" (number->string group-id) "members.json")))+56
+57
;;; List members of a group.+58
(define (discourse-list-group-members base-url api-key username group-name)+59
(discourse-get/json api-key username+60
(discourse-url base-url "groups" group-name "members.json")))+61
+62
))src/discourse/messages.sgladded
@@ -0,0 +1,37 @@
+1
;;; (discourse messages) - Private message management.+2
;;;+3
;;; Functions for sending and listing private messages on Discourse.+4
+5
(define-library (discourse messages)+6
(import (sigil core)+7
(sigil string)+8
(discourse client))+9
+10
(export discourse-send-message+11
discourse-list-messages+12
discourse-list-sent-messages)+13
+14
(begin+15
+16
;;; Send a private message.+17
;;;+18
;;; recipients is a comma-separated string of usernames.+19
(define (discourse-send-message base-url api-key username title raw recipients)+20
(discourse-post/json api-key username+21
(discourse-url base-url "posts.json")+22
#{ title: title+23
raw: raw+24
target_recipients: recipients+25
archetype: "private_message" }))+26
+27
;;; List private messages received by a user.+28
(define (discourse-list-messages base-url api-key username target-user)+29
(discourse-get/json api-key username+30
(discourse-url base-url "topics" "private-messages" (string-append target-user ".json"))))+31
+32
;;; List private messages sent by a user.+33
(define (discourse-list-sent-messages base-url api-key username target-user)+34
(discourse-get/json api-key username+35
(discourse-url base-url "topics" "private-messages-sent" (string-append target-user ".json"))))+36
+37
))src/discourse/posts.sgladded
@@ -0,0 +1,41 @@
+1
;;; (discourse posts) - Post management.+2
;;;+3
;;; Functions for getting, creating, updating, and deleting posts+4
;;; within Discourse topics.+5
+6
(define-library (discourse posts)+7
(import (sigil core)+8
(sigil string)+9
(discourse client))+10
+11
(export discourse-get-post+12
discourse-create-post+13
discourse-update-post+14
discourse-delete-post)+15
+16
(begin+17
+18
;;; Get a specific post by ID.+19
(define (discourse-get-post base-url api-key username post-id)+20
(discourse-get/json api-key username+21
(discourse-url base-url "posts" (string-append (number->string post-id) ".json"))))+22
+23
;;; Create a new post (reply to a topic).+24
(define (discourse-create-post base-url api-key username topic-id raw)+25
(discourse-post/json api-key username+26
(discourse-url base-url "posts.json")+27
#{ topic_id: topic-id+28
raw: raw }))+29
+30
;;; Update a post's content.+31
(define (discourse-update-post base-url api-key username post-id raw)+32
(discourse-put/json api-key username+33
(discourse-url base-url "posts" (string-append (number->string post-id) ".json"))+34
#{ post: #{ raw: raw } }))+35
+36
;;; Delete a post.+37
(define (discourse-delete-post base-url api-key username post-id)+38
(discourse-delete api-key username+39
(discourse-url base-url "posts" (string-append (number->string post-id) ".json"))))+40
+41
))src/discourse/search.sgladded
@@ -0,0 +1,23 @@
+1
;;; (discourse search) - Full-text search.+2
;;;+3
;;; Functions for searching topics, posts, and users on Discourse.+4
+5
(define-library (discourse search)+6
(import (sigil core)+7
(sigil string)+8
(discourse client))+9
+10
(export discourse-search)+11
+12
(begin+13
+14
;;; Search the forum for topics, posts, and users.+15
;;;+16
;;; query supports Discourse search syntax (e.g., "in:title", "#category",+17
;;; "@username", "status:open", etc.).+18
(define (discourse-search base-url api-key username query)+19
(discourse-get/json api-key username+20
(string-append (discourse-url base-url "search.json")+21
"?q=" query)))+22
+23
))src/discourse/topics.sgladded
@@ -0,0 +1,74 @@
+1
;;; (discourse topics) - Topic management.+2
;;;+3
;;; Functions for listing, creating, updating, and managing the status+4
;;; of Discourse topics.+5
+6
(define-library (discourse topics)+7
(import (sigil core)+8
(sigil string)+9
(discourse client))+10
+11
(export discourse-list-latest-topics+12
discourse-list-top-topics+13
discourse-list-category-topics+14
discourse-get-topic+15
discourse-create-topic+16
discourse-update-topic+17
discourse-set-topic-status+18
discourse-delete-topic)+19
+20
(begin+21
+22
;;; List the latest topics on the forum.+23
(define (discourse-list-latest-topics base-url api-key username)+24
(discourse-get/json api-key username+25
(discourse-url base-url "latest.json")))+26
+27
;;; List the top topics on the forum.+28
(define (discourse-list-top-topics base-url api-key username)+29
(discourse-get/json api-key username+30
(discourse-url base-url "top.json")))+31
+32
;;; List topics in a specific category.+33
(define (discourse-list-category-topics base-url api-key username slug category-id)+34
(discourse-get/json api-key username+35
(discourse-url base-url "c" slug (number->string category-id) ".json")))+36
+37
;;; Get a specific topic by ID.+38
(define (discourse-get-topic base-url api-key username topic-id)+39
(discourse-get/json api-key username+40
(discourse-url base-url "t" (string-append (number->string topic-id) ".json"))))+41
+42
;;; Create a new topic.+43
;;;+44
;;; Discourse creates topics via the posts endpoint with a title field.+45
(define (discourse-create-topic base-url api-key username title raw category-id)+46
(discourse-post/json api-key username+47
(discourse-url base-url "posts.json")+48
#{ title: title+49
raw: raw+50
category: category-id }))+51
+52
;;; Update a topic's title or category.+53
(define (discourse-update-topic base-url api-key username topic-id title category-id)+54
(discourse-put/json api-key username+55
(discourse-url base-url "t" "-" (string-append (number->string topic-id) ".json"))+56
#{ title: title+57
category_id: category-id }))+58
+59
;;; Set the status of a topic (closed, pinned, archived, etc.).+60
;;;+61
;;; status: "closed", "pinned", "archived", "visible"+62
;;; enabled: #t to enable, #f to disable+63
(define (discourse-set-topic-status base-url api-key username topic-id status enabled)+64
(discourse-put/json api-key username+65
(discourse-url base-url "t" (number->string topic-id) "status")+66
#{ status: status+67
enabled: (if enabled "true" "false") }))+68
+69
;;; Delete a topic.+70
(define (discourse-delete-topic base-url api-key username topic-id)+71
(discourse-delete api-key username+72
(discourse-url base-url "t" (string-append (number->string topic-id) ".json"))))+73
+74
))src/discourse/users.sgladded
@@ -0,0 +1,75 @@
+1
;;; (discourse users) - User management.+2
;;;+3
;;; Functions for listing, creating, and administering Discourse users.+4
+5
(define-library (discourse users)+6
(import (sigil core)+7
(sigil string)+8
(discourse client))+9
+10
(export discourse-get-user+11
discourse-list-users+12
discourse-create-user+13
discourse-suspend-user+14
discourse-unsuspend-user+15
discourse-activate-user+16
discourse-deactivate-user+17
discourse-log-out-user)+18
+19
(begin+20
+21
;;; Get a user's profile by username.+22
(define (discourse-get-user base-url api-key username target)+23
(discourse-get/json api-key username+24
(discourse-url base-url "users" (string-append target ".json"))))+25
+26
;;; List active users (admin endpoint).+27
(define (discourse-list-users base-url api-key username)+28
(discourse-get/json api-key username+29
(discourse-url base-url "admin" "users" "list" "active.json")))+30
+31
;;; Create a new user.+32
(define (discourse-create-user base-url api-key username name email password target-username)+33
(discourse-post/json api-key username+34
(discourse-url base-url "users.json")+35
#{ name: name+36
email: email+37
password: password+38
username: target-username+39
active: #t+40
approved: #t }))+41
+42
;;; Suspend a user.+43
;;;+44
;;; duration is in days, reason is a string explanation.+45
(define (discourse-suspend-user base-url api-key username user-id duration reason)+46
(discourse-put/json api-key username+47
(discourse-url base-url "admin" "users" (number->string user-id) "suspend.json")+48
#{ suspend_until: (string-append (number->string duration) " days")+49
reason: reason }))+50
+51
;;; Unsuspend a user.+52
(define (discourse-unsuspend-user base-url api-key username user-id)+53
(discourse-put/json api-key username+54
(discourse-url base-url "admin" "users" (number->string user-id) "unsuspend.json")+55
#{}))+56
+57
;;; Activate a user account.+58
(define (discourse-activate-user base-url api-key username user-id)+59
(discourse-put/json api-key username+60
(discourse-url base-url "admin" "users" (number->string user-id) "activate.json")+61
#{}))+62
+63
;;; Deactivate a user account.+64
(define (discourse-deactivate-user base-url api-key username user-id)+65
(discourse-put/json api-key username+66
(discourse-url base-url "admin" "users" (number->string user-id) "deactivate.json")+67
#{}))+68
+69
;;; Force log out a user.+70
(define (discourse-log-out-user base-url api-key username user-id)+71
(discourse-post/json api-key username+72
(discourse-url base-url "admin" "users" (number->string user-id) "log_out.json")+73
#{}))+74
+75
))test/test-client.sgladded
@@ -0,0 +1,48 @@
+1
(import (sigil test)+2
(sigil dict)+3
(discourse client))+4
+5
;; ============================================================+6
;; discourse-auth-headers+7
;; ============================================================+8
+9
(test-group "discourse-auth-headers"+10
(test "returns dict with Api-Key and Api-Username"+11
(let ((headers (discourse-auth-headers "my-key" "admin")))+12
(assert-equal "my-key" (dict-ref headers Api-Key:))+13
(assert-equal "admin" (dict-ref headers Api-Username:))))+14
+15
(test "handles empty strings"+16
(let ((headers (discourse-auth-headers "" "")))+17
(assert-equal "" (dict-ref headers Api-Key:))+18
(assert-equal "" (dict-ref headers Api-Username:)))))+19
+20
;; ============================================================+21
;; discourse-url+22
;; ============================================================+23
+24
(test-group "discourse-url"+25
(test "joins base URL with single path segment"+26
(assert-equal "https://forum.example.com/latest.json"+27
(discourse-url "https://forum.example.com" "latest.json")))+28
+29
(test "joins base URL with multiple path segments"+30
(assert-equal "https://forum.example.com/t/123.json"+31
(discourse-url "https://forum.example.com" "t" "123.json")))+32
+33
(test "does not add /api/v1 prefix"+34
(assert-equal "https://forum.example.com/categories.json"+35
(discourse-url "https://forum.example.com" "categories.json")))+36
+37
(test "handles deeply nested paths"+38
(assert-equal "https://forum.example.com/admin/users/list/active.json"+39
(discourse-url "https://forum.example.com"+40
"admin" "users" "list" "active.json")))+41
+42
(test "handles no path segments"+43
(assert-equal "https://forum.example.com"+44
(discourse-url "https://forum.example.com")))+45
+46
(test "handles base URL with trailing slash"+47
(assert-equal "https://forum.example.com//latest.json"+48
(discourse-url "https://forum.example.com/" "latest.json"))))