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-Clause
dev-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"))))