circle-so 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +17 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +145 -0
  5. data/lib/circle-so.rb +5 -0
  6. data/lib/circle_so/client.rb +147 -0
  7. data/lib/circle_so/errors.rb +39 -0
  8. data/lib/circle_so/resources/access_groups.rb +21 -0
  9. data/lib/circle_so/resources/base.rb +15 -0
  10. data/lib/circle_so/resources/chat_preferences.rb +9 -0
  11. data/lib/circle_so/resources/comments.rb +12 -0
  12. data/lib/circle_so/resources/community.rb +10 -0
  13. data/lib/circle_so/resources/community_members.rb +32 -0
  14. data/lib/circle_so/resources/community_segments.rb +13 -0
  15. data/lib/circle_so/resources/course_lessons.rb +16 -0
  16. data/lib/circle_so/resources/course_sections.rb +13 -0
  17. data/lib/circle_so/resources/direct_uploads.rb +9 -0
  18. data/lib/circle_so/resources/embeds.rb +10 -0
  19. data/lib/circle_so/resources/event_attendees.rb +11 -0
  20. data/lib/circle_so/resources/events.rb +14 -0
  21. data/lib/circle_so/resources/flagged_contents.rb +10 -0
  22. data/lib/circle_so/resources/forms.rb +13 -0
  23. data/lib/circle_so/resources/gamification.rb +9 -0
  24. data/lib/circle_so/resources/images.rb +12 -0
  25. data/lib/circle_so/resources/invitation_links.rb +11 -0
  26. data/lib/circle_so/resources/member_tags.rb +13 -0
  27. data/lib/circle_so/resources/messages.rb +9 -0
  28. data/lib/circle_so/resources/page_profile_fields.rb +9 -0
  29. data/lib/circle_so/resources/posts.rb +15 -0
  30. data/lib/circle_so/resources/profile_fields.rb +11 -0
  31. data/lib/circle_so/resources/search.rb +9 -0
  32. data/lib/circle_so/resources/space_group_members.rb +15 -0
  33. data/lib/circle_so/resources/space_groups.rb +13 -0
  34. data/lib/circle_so/resources/space_members.rb +15 -0
  35. data/lib/circle_so/resources/spaces.rb +14 -0
  36. data/lib/circle_so/resources/tagged_members.rb +13 -0
  37. data/lib/circle_so/resources/topics.rb +13 -0
  38. data/lib/circle_so/token.rb +38 -0
  39. data/lib/circle_so/version.rb +5 -0
  40. data/lib/circle_so.rb +44 -0
  41. metadata +86 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f9168b54a09c87d1ed7adc9abdd8946b030d06e984ecef02b43eb8f201745e2e
4
+ data.tar.gz: '01888d6cfe6da5d686c47c5ab750a14c628fa025c09dac1445ea0b02d63905f9'
5
+ SHA512:
6
+ metadata.gz: 5ede5321b909824ba1e0c16167f554fed5f791dd6defe4689678355e4b401fe98ecafbbb02fd23bfb36af500acfe542b17b952bb60c690fffe401d597faeef95
7
+ data.tar.gz: 41f64f4e5007116a4a552967f0acb701f457d19ff17f1beeb7b0a766f9179b44144e3e5224b8090767d4766f76a67d10ba24eea04aca7d7df1b6f76386aa3967
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-06
4
+
5
+ - Initial release.
6
+ - Full coverage of the Circle.so Admin API v2 (68 endpoints across 30
7
+ resources): community, community members, access groups, spaces, space
8
+ groups, space/space-group membership, posts, comments, events, event
9
+ attendees, courses (lessons, sections, progress), community segments,
10
+ member tags, tagged members, topics, forms, flagged contents, invitation
11
+ links, messages, direct uploads, embeds, images, profile fields, page
12
+ profile fields, gamification leaderboard, advanced search, chat
13
+ preferences.
14
+ - Classified errors (`CircleSo::APIError` subclasses) including the
15
+ 404-HTML "wrong path" guard.
16
+ - Three-state token version detection (`CircleSo.detect_token_version`).
17
+ - Zero runtime dependencies.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lawrence Lin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # circle-so
2
+
3
+ Zero-dependency Ruby client for the [Circle.so](https://circle.so) Admin API v2 — full coverage of all 68 endpoints ([official docs](https://api.circle.so/)): members, spaces, space groups, access groups, posts, comments, events, courses, segments, tags, and more, with classified errors and token version detection.
4
+
5
+ > Unofficial. Not affiliated with Circle Internet Services, Inc.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gem install circle-so
11
+ ```
12
+
13
+ Or in your Gemfile:
14
+
15
+ ```ruby
16
+ gem "circle-so"
17
+ ```
18
+
19
+ Requires Ruby ≥ 3.1. No runtime dependencies (built on `net/http`).
20
+
21
+ ## Quick start
22
+
23
+ ```ruby
24
+ require "circle_so"
25
+
26
+ client = CircleSo::Client.new(token: ENV["CIRCLE_SO_TOKEN"])
27
+
28
+ community = client.community.show
29
+
30
+ # Invite a member (idempotent — re-inviting an existing email does not
31
+ # create a duplicate or re-send the invitation email):
32
+ result = client.community_members.invite(email: "user@example.com", name: "User Name")
33
+ result["community_member"]["id"]
34
+
35
+ # Access groups (member identified by email, group id in the URL):
36
+ client.access_groups.list
37
+ client.access_groups.add_member(group_id, email: "user@example.com")
38
+ client.access_groups.remove_member(group_id, email: "user@example.com")
39
+
40
+ # Spaces / space groups (id goes in the request BODY, even for DELETE):
41
+ client.space_members.add(space_id: 123, email: "user@example.com")
42
+ client.space_group_members.add(space_group_id: 456, email: "user@example.com")
43
+
44
+ # Posts, events, courses, ...
45
+ client.posts.list(space_id: 123)
46
+ client.events.create(name: "Launch party", space_id: 123)
47
+ client.course_lessons.update_progress(lesson_id: 1, user_email: "user@example.com")
48
+ ```
49
+
50
+ All responses are parsed JSON (`Hash`/`Array` with string keys), exactly as Circle returns them.
51
+
52
+ ## Resources
53
+
54
+ Every Admin API v2 endpoint is covered:
55
+
56
+ | `client.` … | Methods |
57
+ |---|---|
58
+ | `access_groups` | `list`, `create`, `update`, `archive`, `unarchive`, `members`, `member`, `add_member`, `remove_member` |
59
+ | `chat_preferences` | `update` |
60
+ | `comments` | `list`, `create`, `show`, `delete` |
61
+ | `community` | `show`, `update` |
62
+ | `community_members` | `list`, `search`, `show`, `update`, `invite`/`create`, `deactivate`, `delete`, `ban`, `access_groups`, `spaces` |
63
+ | `community_segments` | `list`, `create`, `update`, `delete`, `duplicate` |
64
+ | `course_lessons` | `list`, `create`, `show`, `update`, `delete`, `update_progress` |
65
+ | `course_sections` | `list`, `create`, `show`, `update`, `delete` |
66
+ | `direct_uploads` | `create` |
67
+ | `embeds` | `create`, `show` |
68
+ | `event_attendees` | `list`, `add`, `remove` |
69
+ | `events` | `list`, `create`, `show`, `update`, `delete`, `duplicate` |
70
+ | `flagged_contents` | `list`, `create` |
71
+ | `forms` | `list`, `show`, `update`, `delete`, `duplicate` |
72
+ | `gamification` | `leaderboard` |
73
+ | `images` | `create_post`, `delete_post`, `duplicate_post` |
74
+ | `invitation_links` | `list`, `delete`, `revoke` |
75
+ | `member_tags` | `list`, `create`, `show`, `update`, `delete` |
76
+ | `messages` | `create` |
77
+ | `page_profile_fields` | `list` |
78
+ | `posts` | `list`, `create`, `show`, `update`, `delete`, `summary`, `unfollow` |
79
+ | `profile_fields` | `list`, `archive`, `unarchive` |
80
+ | `search` | `advanced` |
81
+ | `space_group_members` | `list`, `show`, `add`, `remove` |
82
+ | `space_groups` | `list`, `create`, `show`, `update`, `delete` |
83
+ | `space_members` | `list`, `show`, `add`, `remove` |
84
+ | `spaces` | `list`, `create`, `show`, `update`, `delete`, `ai_summaries` |
85
+ | `tagged_members` | `list`, `show`, `add`, `remove` |
86
+ | `topics` | `list`, `create`, `show`, `update`, `delete` |
87
+
88
+ List endpoints accept query params as keyword arguments (`page:`, `per_page:`, filters). Create/update endpoints accept the request body as keyword arguments. There is also a low-level escape hatch: `client.get(path, params)`, `client.post(path, body)`, etc.
89
+
90
+ ## Error handling
91
+
92
+ Non-2xx responses raise a subclass of `CircleSo::APIError` with a classified `kind`, the HTTP `status`, and the parsed response `body`:
93
+
94
+ | class | kind | meaning |
95
+ |---|---|---|
96
+ | `CircleSo::UnauthorizedError` | `:unauthorized` | 401 — wrong/invalid token (or a v1 token on the v2 API) |
97
+ | `CircleSo::ForbiddenError` | `:forbidden` | 403 — plan no longer allows the endpoint |
98
+ | `CircleSo::NotFoundError` | `:not_found` | 404 with a JSON body — missing record/param |
99
+ | `CircleSo::WrongPathError` | `:wrong_path` | 404 with an **HTML** body — the route does not exist (bug guard) |
100
+ | `CircleSo::RateLimitedError` | `:rate_limited` | 429 |
101
+ | `CircleSo::ServerError` | `:server_error` | 5xx |
102
+
103
+ ```ruby
104
+ begin
105
+ client.access_groups.add_member(group_id, email: email)
106
+ rescue CircleSo::UnauthorizedError
107
+ # pause syncing, prompt the user to re-connect
108
+ rescue CircleSo::APIError => e
109
+ logger.error("Circle #{e.status} (#{e.kind}): #{e.message}")
110
+ end
111
+ ```
112
+
113
+ The `wrong_path` distinction matters: Circle returns 404 **HTML** for routes that don't exist and 404 **JSON** for missing records. The client tells them apart via `Content-Type` so a typo'd endpoint doesn't masquerade as "record not found".
114
+
115
+ ## Token version detection
116
+
117
+ Circle issues v1 (Professional plan) and v2 (Business plan) admin tokens that look identical. A v1 token on the v2 API returns a plain 401, so use the three-state probe to give users an actionable error:
118
+
119
+ ```ruby
120
+ CircleSo.detect_token_version(token) # => :v2, :v1, or :invalid
121
+ ```
122
+
123
+ ## API quirks this client encodes
124
+
125
+ Learned from a live integration (the test fixtures under `spec/fixtures` were captured against the real API):
126
+
127
+ - **Asymmetric membership endpoints.** Access groups take the group id in the URL and the member's *email* in the body (`POST /access_groups/:id/community_members`). Spaces and space groups instead use flat endpoints (`/space_members`, `/space_group_members`) with the id in the body — even for `DELETE`.
128
+ - **Members are keyed by email**, not member id, for all membership operations.
129
+ - **Adds/removes are idempotent.** Re-adding returns the same success response; you can apply a full expected set without querying current state first.
130
+ - **Space group membership cascades** to all spaces inside the group.
131
+ - **First `invite` sends Circle's invitation email**; subsequent calls for the same email are no-ops (the response message says "already a member").
132
+ - **`course_lessons` update uses `PATCH`** while every other update uses `PUT`; `tagged_members.remove` identifies the record via query params.
133
+
134
+ ## Development
135
+
136
+ ```bash
137
+ bundle install
138
+ bundle exec rake spec # runs the suite with 100% line-coverage enforcement
139
+ ```
140
+
141
+ Tests stub Circle at the network boundary with [WebMock](https://github.com/bblimke/webmock), replaying real response bodies captured from the live Admin API — no credentials needed, fully deterministic.
142
+
143
+ ## License
144
+
145
+ MIT
data/lib/circle-so.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shim so both `require "circle-so"` (the gem name) and `require "circle_so"`
4
+ # work.
5
+ require_relative "circle_so"
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module CircleSo
8
+ # HTTP client for the Circle.so Admin API v2.
9
+ #
10
+ # client = CircleSo::Client.new(token: ENV["CIRCLE_SO_TOKEN"])
11
+ # client.community.show
12
+ # client.community_members.invite(email: "user@example.com")
13
+ #
14
+ # Zero runtime dependencies — built on Net::HTTP. All responses are parsed
15
+ # JSON (Hash/Array with string keys). Non-2xx responses raise a subclass of
16
+ # CircleSo::APIError (see errors.rb for the classification).
17
+ class Client
18
+ BASE_URL = "https://app.circle.so/api/admin/v2"
19
+
20
+ attr_reader :base_url
21
+
22
+ def initialize(token:, base_url: BASE_URL, open_timeout: 10, read_timeout: 60)
23
+ raise ArgumentError, "token is required" if token.nil? || token.empty?
24
+
25
+ @token = token
26
+ @base_url = base_url.chomp("/")
27
+ @open_timeout = open_timeout
28
+ @read_timeout = read_timeout
29
+ end
30
+
31
+ # -- resources ----------------------------------------------------------
32
+
33
+ def access_groups = @access_groups ||= Resources::AccessGroups.new(self)
34
+ def chat_preferences = @chat_preferences ||= Resources::ChatPreferences.new(self)
35
+ def comments = @comments ||= Resources::Comments.new(self)
36
+ def community = @community ||= Resources::Community.new(self)
37
+ def community_members = @community_members ||= Resources::CommunityMembers.new(self)
38
+ def community_segments = @community_segments ||= Resources::CommunitySegments.new(self)
39
+ def course_lessons = @course_lessons ||= Resources::CourseLessons.new(self)
40
+ def course_sections = @course_sections ||= Resources::CourseSections.new(self)
41
+ def direct_uploads = @direct_uploads ||= Resources::DirectUploads.new(self)
42
+ def embeds = @embeds ||= Resources::Embeds.new(self)
43
+ def event_attendees = @event_attendees ||= Resources::EventAttendees.new(self)
44
+ def events = @events ||= Resources::Events.new(self)
45
+ def flagged_contents = @flagged_contents ||= Resources::FlaggedContents.new(self)
46
+ def forms = @forms ||= Resources::Forms.new(self)
47
+ def gamification = @gamification ||= Resources::Gamification.new(self)
48
+ def images = @images ||= Resources::Images.new(self)
49
+ def invitation_links = @invitation_links ||= Resources::InvitationLinks.new(self)
50
+ def member_tags = @member_tags ||= Resources::MemberTags.new(self)
51
+ def messages = @messages ||= Resources::Messages.new(self)
52
+ def page_profile_fields = @page_profile_fields ||= Resources::PageProfileFields.new(self)
53
+ def posts = @posts ||= Resources::Posts.new(self)
54
+ def profile_fields = @profile_fields ||= Resources::ProfileFields.new(self)
55
+ def search = @search ||= Resources::Search.new(self)
56
+ def space_group_members = @space_group_members ||= Resources::SpaceGroupMembers.new(self)
57
+ def space_groups = @space_groups ||= Resources::SpaceGroups.new(self)
58
+ def space_members = @space_members ||= Resources::SpaceMembers.new(self)
59
+ def spaces = @spaces ||= Resources::Spaces.new(self)
60
+ def tagged_members = @tagged_members ||= Resources::TaggedMembers.new(self)
61
+ def topics = @topics ||= Resources::Topics.new(self)
62
+
63
+ # -- low-level requests (public escape hatch) ---------------------------
64
+
65
+ def get(path, params = {}) = request(Net::HTTP::Get, path, params: params)
66
+ def post(path, body = nil, params = {}) = request(Net::HTTP::Post, path, body: body, params: params)
67
+ def put(path, body = nil, params = {}) = request(Net::HTTP::Put, path, body: body, params: params)
68
+ def patch(path, body = nil, params = {}) = request(Net::HTTP::Patch, path, body: body, params: params)
69
+ def delete(path, body = nil, params = {}) = request(Net::HTTP::Delete, path, body: body, params: params)
70
+
71
+ private
72
+
73
+ def request(verb, path, params: {}, body: nil)
74
+ uri = build_uri(path, params)
75
+ req = verb.new(uri)
76
+ req["Authorization"] = "Bearer #{@token}"
77
+ req["Accept"] = "application/json"
78
+ req["User-Agent"] = "circle-so ruby gem #{CircleSo::VERSION}"
79
+ if body
80
+ req["Content-Type"] = "application/json"
81
+ req.body = JSON.generate(body)
82
+ end
83
+
84
+ res = http_for(uri).request(req)
85
+ handle_response(res, verb, path)
86
+ end
87
+
88
+ def build_uri(path, params)
89
+ uri = URI.parse("#{@base_url}#{path}")
90
+ compact = params.reject { |_k, v| v.nil? }
91
+ uri.query = [uri.query, URI.encode_www_form(compact)].compact.reject(&:empty?).join("&") unless compact.empty?
92
+ uri
93
+ end
94
+
95
+ def http_for(uri)
96
+ http = Net::HTTP.new(uri.host, uri.port)
97
+ http.use_ssl = uri.scheme == "https"
98
+ http.open_timeout = @open_timeout
99
+ http.read_timeout = @read_timeout
100
+ http
101
+ end
102
+
103
+ def handle_response(res, verb, path)
104
+ status = res.code.to_i
105
+ json = json_response?(res)
106
+ parsed = json ? parse_json(res.body) : res.body
107
+
108
+ return parsed if (200..299).cover?(status)
109
+
110
+ # Circle returns 404 with an HTML body when the route does not exist
111
+ # (vs 404 JSON for a missing record) — surface that as a distinct error
112
+ # so a typo'd endpoint doesn't masquerade as "record not found".
113
+ method_name = verb.name.split("::").last.upcase
114
+ if status == 404 && !json
115
+ raise WrongPathError.new(
116
+ "Circle returned 404 HTML for #{method_name} #{path} (route does not exist)",
117
+ status: 404, body: res.body, kind: :wrong_path
118
+ )
119
+ end
120
+
121
+ message = (parsed.is_a?(Hash) && parsed["message"]) || "#{method_name} #{path} failed with status #{status}"
122
+ klass, kind = classify_status(status)
123
+ raise klass.new(message, status: status, body: parsed, kind: kind)
124
+ end
125
+
126
+ def json_response?(res)
127
+ (res["content-type"] || "").include?("application/json")
128
+ end
129
+
130
+ def parse_json(body)
131
+ body.nil? || body.empty? ? nil : JSON.parse(body)
132
+ rescue JSON::ParserError
133
+ body
134
+ end
135
+
136
+ def classify_status(status)
137
+ case status
138
+ when 401 then [UnauthorizedError, :unauthorized]
139
+ when 403 then [ForbiddenError, :forbidden]
140
+ when 404 then [NotFoundError, :not_found]
141
+ when 429 then [RateLimitedError, :rate_limited]
142
+ when 500..599 then [ServerError, :server_error]
143
+ else [APIError, :unknown]
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ class Error < StandardError; end
5
+
6
+ # Raised for any non-2xx response from the Circle API. Carries the HTTP
7
+ # status, the parsed response body (Hash when JSON, String otherwise), and
8
+ # a classification kind mirroring the status:
9
+ #
10
+ # :unauthorized 401 — wrong/invalid token (or a v1 token on the v2 API)
11
+ # :forbidden 403 — plan no longer allows the endpoint
12
+ # :not_found 404 with a JSON body — missing record/param
13
+ # :wrong_path 404 with an HTML body — the route does not exist
14
+ # :rate_limited 429
15
+ # :server_error 5xx
16
+ # :unknown anything else
17
+ class APIError < Error
18
+ attr_reader :status, :body, :kind
19
+
20
+ def initialize(message, status:, body: nil, kind: :unknown)
21
+ super(message)
22
+ @status = status
23
+ @body = body
24
+ @kind = kind
25
+ end
26
+ end
27
+
28
+ class UnauthorizedError < APIError; end
29
+ class ForbiddenError < APIError; end
30
+ class NotFoundError < APIError; end
31
+
32
+ # Circle returns 404 with an HTML body when the route itself does not exist
33
+ # (as opposed to 404 JSON for a missing record). Getting this error means
34
+ # the request hit a non-existent endpoint — a bug, not missing data.
35
+ class WrongPathError < APIError; end
36
+
37
+ class RateLimitedError < APIError; end
38
+ class ServerError < APIError; end
39
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class AccessGroups < Base
6
+ def list(**params) = client.get("/access_groups", params)
7
+ def create(**attrs) = client.post("/access_groups", attrs)
8
+ def update(id, **attrs) = client.put("/access_groups/#{id}", attrs)
9
+ # DELETE archives the access group (Circle keeps the record).
10
+ def archive(id) = client.delete("/access_groups/#{id}")
11
+ def unarchive(id) = client.patch("/access_groups/#{id}/unarchive")
12
+
13
+ # Membership: the group id goes in the URL, the member is identified by
14
+ # email (not member id) in the body/query.
15
+ def members(access_group_id, **params) = client.get("/access_groups/#{access_group_id}/community_members", params)
16
+ def member(access_group_id, email:) = client.get("/access_groups/#{access_group_id}/community_member", {email: email})
17
+ def add_member(access_group_id, email:, **attrs) = client.post("/access_groups/#{access_group_id}/community_members", {email: email, **attrs})
18
+ def remove_member(access_group_id, email:) = client.delete("/access_groups/#{access_group_id}/community_members", {email: email})
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Base
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ private
11
+
12
+ attr_reader :client
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class ChatPreferences < Base
6
+ def update(**attrs) = client.put("/chat_preferences", attrs)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Comments < Base
6
+ def list(**params) = client.get("/comments", params)
7
+ def create(**attrs) = client.post("/comments", attrs)
8
+ def show(id) = client.get("/comments/#{id}")
9
+ def delete(id) = client.delete("/comments/#{id}")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Community < Base
6
+ def show = client.get("/community")
7
+ def update(**attrs) = client.put("/community", attrs)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class CommunityMembers < Base
6
+ def list(**params) = client.get("/community_members", params)
7
+ def search(**params) = client.get("/community_members/search", params)
8
+ def show(id) = client.get("/community_members/#{id}")
9
+ def update(id, **attrs) = client.put("/community_members/#{id}", attrs)
10
+
11
+ # Create/invite a member by email. Idempotent: re-calling for an
12
+ # existing email returns the same member (no duplicate, no re-sent
13
+ # invitation email). The response "message" says "already a member"
14
+ # in that case. First creation triggers Circle's invitation email.
15
+ def invite(email:, **attrs) = client.post("/community_members", {email: email, **attrs})
16
+ alias_method :create, :invite
17
+
18
+ # Deactivate the member (removes community access, keeps the record).
19
+ def deactivate(id) = client.delete("/community_members/#{id}")
20
+ # Permanently delete the member. Note: Circle models this as a PUT.
21
+ def delete(id) = client.put("/community_members/#{id}/delete_member")
22
+ def ban(id) = client.put("/community_members/#{id}/ban_member")
23
+
24
+ # Access groups this member belongs to.
25
+ def access_groups(id, **params) = client.get("/community_members/#{id}/access_groups", params)
26
+
27
+ # Spaces a member belongs to. Filter with community_member_id: or
28
+ # user_email: (query params).
29
+ def spaces(**params) = client.get("/community_member_spaces", params)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class CommunitySegments < Base
6
+ def list(**params) = client.get("/community_segments", params)
7
+ def create(**attrs) = client.post("/community_segments", attrs)
8
+ def update(id, **attrs) = client.put("/community_segments/#{id}", attrs)
9
+ def delete(id) = client.delete("/community_segments/#{id}")
10
+ def duplicate(id) = client.post("/community_segments/#{id}/duplicate")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class CourseLessons < Base
6
+ def list(**params) = client.get("/course_lessons", params)
7
+ def create(**attrs) = client.post("/course_lessons", attrs)
8
+ def show(id) = client.get("/course_lessons/#{id}")
9
+ # Note: Circle uses PATCH here (course sections use PUT).
10
+ def update(id, **attrs) = client.patch("/course_lessons/#{id}", attrs)
11
+ def delete(id) = client.delete("/course_lessons/#{id}")
12
+
13
+ def update_progress(**attrs) = client.put("/course_lesson_progress", attrs)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class CourseSections < Base
6
+ def list(**params) = client.get("/course_sections", params)
7
+ def create(**attrs) = client.post("/course_sections", attrs)
8
+ def show(id) = client.get("/course_sections/#{id}")
9
+ def update(id, **attrs) = client.put("/course_sections/#{id}", attrs)
10
+ def delete(id) = client.delete("/course_sections/#{id}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class DirectUploads < Base
6
+ def create(**attrs) = client.post("/direct_uploads", attrs)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Embeds < Base
6
+ def create(**attrs) = client.post("/embeds", attrs)
7
+ def show(sgid) = client.get("/embeds/#{sgid}")
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class EventAttendees < Base
6
+ def list(**params) = client.get("/event_attendees", params)
7
+ def add(**attrs) = client.post("/event_attendees", attrs)
8
+ def remove(**attrs) = client.delete("/event_attendees", attrs)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Events < Base
6
+ def list(**params) = client.get("/events", params)
7
+ def create(**attrs) = client.post("/events", attrs)
8
+ def show(id) = client.get("/events/#{id}")
9
+ def update(id, **attrs) = client.put("/events/#{id}", attrs)
10
+ def delete(id) = client.delete("/events/#{id}")
11
+ def duplicate(id, space_id:, **attrs) = client.post("/spaces/#{space_id}/events/#{id}/duplicate", attrs)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class FlaggedContents < Base
6
+ def list(**params) = client.get("/flagged_contents", params)
7
+ def create(**attrs) = client.post("/flagged_contents", attrs)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Forms < Base
6
+ def list(**params) = client.get("/forms", params)
7
+ def show(id) = client.get("/forms/#{id}")
8
+ def update(id, **attrs) = client.put("/forms/#{id}", attrs)
9
+ def delete(id) = client.delete("/forms/#{id}")
10
+ def duplicate(id) = client.post("/forms/#{id}/duplicate")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Gamification < Base
6
+ def leaderboard(**params) = client.get("/gamification/leaderboard", params)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ # Image posts inside a space (/spaces/{space_id}/images/posts).
6
+ class Images < Base
7
+ def create_post(space_id, **attrs) = client.post("/spaces/#{space_id}/images/posts", attrs)
8
+ def delete_post(space_id, id) = client.delete("/spaces/#{space_id}/images/posts/#{id}")
9
+ def duplicate_post(space_id, id) = client.post("/spaces/#{space_id}/images/posts/#{id}/duplicate")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class InvitationLinks < Base
6
+ def list(**params) = client.get("/invitation_links", params)
7
+ def delete(id) = client.delete("/invitation_links/#{id}")
8
+ def revoke(id) = client.patch("/invitation_links/#{id}/revoke")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class MemberTags < Base
6
+ def list(**params) = client.get("/member_tags", params)
7
+ def create(**attrs) = client.post("/member_tags", attrs)
8
+ def show(id) = client.get("/member_tags/#{id}")
9
+ def update(id, **attrs) = client.put("/member_tags/#{id}", attrs)
10
+ def delete(id) = client.delete("/member_tags/#{id}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Messages < Base
6
+ def create(**attrs) = client.post("/messages", attrs)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class PageProfileFields < Base
6
+ def list(**params) = client.get("/page_profile_fields", params)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Posts < Base
6
+ def list(**params) = client.get("/posts", params)
7
+ def create(**attrs) = client.post("/posts", attrs)
8
+ def show(id) = client.get("/posts/#{id}")
9
+ def update(id, **attrs) = client.put("/posts/#{id}", attrs)
10
+ def delete(id) = client.delete("/posts/#{id}")
11
+ def summary(post_id) = client.get("/posts/#{post_id}/summary")
12
+ def unfollow(post_id, community_member_id:) = client.delete("/posts/#{post_id}/post_followers", nil, {community_member_id: community_member_id})
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class ProfileFields < Base
6
+ def list(**params) = client.get("/profile_fields", params)
7
+ def archive(id) = client.put("/profile_fields/#{id}/archive")
8
+ def unarchive(id) = client.put("/profile_fields/#{id}/unarchive")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Search < Base
6
+ def advanced(query:, **params) = client.get("/advanced_search", {query: query, **params})
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ # Space group membership uses flat endpoints: the space_group_id goes in
6
+ # the request body (even for DELETE), not the URL. Members are identified
7
+ # by email. Adding a member to a space group cascades into all its spaces.
8
+ class SpaceGroupMembers < Base
9
+ def list(**params) = client.get("/space_group_members", params)
10
+ def show(space_group_id:, email:) = client.get("/space_group_member", {space_group_id: space_group_id, email: email})
11
+ def add(space_group_id:, email:, **attrs) = client.post("/space_group_members", {space_group_id: space_group_id, email: email, **attrs})
12
+ def remove(space_group_id:, email:) = client.delete("/space_group_members", {space_group_id: space_group_id, email: email})
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class SpaceGroups < Base
6
+ def list(**params) = client.get("/space_groups", params)
7
+ def create(**attrs) = client.post("/space_groups", attrs)
8
+ def show(id) = client.get("/space_groups/#{id}")
9
+ def update(id, **attrs) = client.put("/space_groups/#{id}", attrs)
10
+ def delete(id) = client.delete("/space_groups/#{id}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ # Space membership uses flat endpoints: the space_id goes in the request
6
+ # body (even for DELETE), not the URL. Members are identified by email.
7
+ # Adds/removes are idempotent.
8
+ class SpaceMembers < Base
9
+ def list(**params) = client.get("/space_members", params)
10
+ def show(space_id:, email:) = client.get("/space_member", {space_id: space_id, email: email})
11
+ def add(space_id:, email:, **attrs) = client.post("/space_members", {space_id: space_id, email: email, **attrs})
12
+ def remove(space_id:, email:) = client.delete("/space_members", {space_id: space_id, email: email})
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Spaces < Base
6
+ def list(**params) = client.get("/spaces", params)
7
+ def create(**attrs) = client.post("/spaces", attrs)
8
+ def show(id) = client.get("/spaces/#{id}")
9
+ def update(id, **attrs) = client.put("/spaces/#{id}", attrs)
10
+ def delete(id) = client.delete("/spaces/#{id}")
11
+ def ai_summaries(space_id, **params) = client.get("/spaces/#{space_id}/ai_summaries", params)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class TaggedMembers < Base
6
+ def list(**params) = client.get("/tagged_members", params)
7
+ def show(id) = client.get("/tagged_members/#{id}")
8
+ def add(**attrs) = client.post("/tagged_members", attrs)
9
+ # Removal identifies the record by query params, not the body.
10
+ def remove(user_email:, member_tag_id:) = client.delete("/tagged_members", nil, {user_email: user_email, member_tag_id: member_tag_id})
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ module Resources
5
+ class Topics < Base
6
+ def list(**params) = client.get("/topics", params)
7
+ def create(**attrs) = client.post("/topics", attrs)
8
+ def show(id) = client.get("/topics/#{id}")
9
+ def update(id, **attrs) = client.put("/topics/#{id}", attrs)
10
+ def delete(id) = client.delete("/topics/#{id}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+
6
+ module CircleSo
7
+ # Circle issues two admin token types that look identical but hit different
8
+ # APIs:
9
+ #
10
+ # :v2 Business-plan token — works against /api/admin/v2
11
+ # :v1 Professional-plan token — only /api/v1 (no access groups)
12
+ # :invalid not a usable token
13
+ #
14
+ # A v1 token on a v2 endpoint returns a plain HTTP 401, indistinguishable
15
+ # from an invalid token — so probe v2 first, then v1, to give users an
16
+ # actionable three-state answer.
17
+ module Token
18
+ BASE_URL_V1 = "https://app.circle.so/api/v1"
19
+
20
+ module_function
21
+
22
+ def detect_version(token, base_url_v2: Client::BASE_URL, base_url_v1: BASE_URL_V1)
23
+ return :v2 if probe(base_url_v2, "Bearer #{token}")
24
+ return :v1 if probe(base_url_v1, "Token #{token}")
25
+
26
+ :invalid
27
+ end
28
+
29
+ def probe(base_url, authorization)
30
+ uri = URI.parse("#{base_url.chomp("/")}/community_members?per_page=1")
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ http.use_ssl = uri.scheme == "https"
33
+ res = http.request(Net::HTTP::Get.new(uri).tap { |r| r["Authorization"] = authorization })
34
+ (200..299).cover?(res.code.to_i)
35
+ end
36
+ private_class_method :probe
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleSo
4
+ VERSION = "0.1.0"
5
+ end
data/lib/circle_so.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "circle_so/version"
4
+ require_relative "circle_so/errors"
5
+ require_relative "circle_so/resources/base"
6
+ require_relative "circle_so/resources/access_groups"
7
+ require_relative "circle_so/resources/chat_preferences"
8
+ require_relative "circle_so/resources/comments"
9
+ require_relative "circle_so/resources/community"
10
+ require_relative "circle_so/resources/community_members"
11
+ require_relative "circle_so/resources/community_segments"
12
+ require_relative "circle_so/resources/course_lessons"
13
+ require_relative "circle_so/resources/course_sections"
14
+ require_relative "circle_so/resources/direct_uploads"
15
+ require_relative "circle_so/resources/embeds"
16
+ require_relative "circle_so/resources/event_attendees"
17
+ require_relative "circle_so/resources/events"
18
+ require_relative "circle_so/resources/flagged_contents"
19
+ require_relative "circle_so/resources/forms"
20
+ require_relative "circle_so/resources/gamification"
21
+ require_relative "circle_so/resources/images"
22
+ require_relative "circle_so/resources/invitation_links"
23
+ require_relative "circle_so/resources/member_tags"
24
+ require_relative "circle_so/resources/messages"
25
+ require_relative "circle_so/resources/page_profile_fields"
26
+ require_relative "circle_so/resources/posts"
27
+ require_relative "circle_so/resources/profile_fields"
28
+ require_relative "circle_so/resources/search"
29
+ require_relative "circle_so/resources/space_group_members"
30
+ require_relative "circle_so/resources/space_groups"
31
+ require_relative "circle_so/resources/space_members"
32
+ require_relative "circle_so/resources/spaces"
33
+ require_relative "circle_so/resources/tagged_members"
34
+ require_relative "circle_so/resources/topics"
35
+ require_relative "circle_so/client"
36
+ require_relative "circle_so/token"
37
+
38
+ module CircleSo
39
+ # Convenience constructor: CircleSo.client(token: "...")
40
+ def self.client(**options) = Client.new(**options)
41
+
42
+ # Three-state token probe: :v2, :v1, or :invalid. See CircleSo::Token.
43
+ def self.detect_token_version(token, **options) = Token.detect_version(token, **options)
44
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: circle-so
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Lawrence Lin
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Zero-dependency Ruby client for the Circle.so Admin API v2 — members,
13
+ spaces, space groups, access groups, posts, events, courses and more, with classified
14
+ errors and token version detection.
15
+ email:
16
+ - lawrence@kaik.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - lib/circle-so.rb
25
+ - lib/circle_so.rb
26
+ - lib/circle_so/client.rb
27
+ - lib/circle_so/errors.rb
28
+ - lib/circle_so/resources/access_groups.rb
29
+ - lib/circle_so/resources/base.rb
30
+ - lib/circle_so/resources/chat_preferences.rb
31
+ - lib/circle_so/resources/comments.rb
32
+ - lib/circle_so/resources/community.rb
33
+ - lib/circle_so/resources/community_members.rb
34
+ - lib/circle_so/resources/community_segments.rb
35
+ - lib/circle_so/resources/course_lessons.rb
36
+ - lib/circle_so/resources/course_sections.rb
37
+ - lib/circle_so/resources/direct_uploads.rb
38
+ - lib/circle_so/resources/embeds.rb
39
+ - lib/circle_so/resources/event_attendees.rb
40
+ - lib/circle_so/resources/events.rb
41
+ - lib/circle_so/resources/flagged_contents.rb
42
+ - lib/circle_so/resources/forms.rb
43
+ - lib/circle_so/resources/gamification.rb
44
+ - lib/circle_so/resources/images.rb
45
+ - lib/circle_so/resources/invitation_links.rb
46
+ - lib/circle_so/resources/member_tags.rb
47
+ - lib/circle_so/resources/messages.rb
48
+ - lib/circle_so/resources/page_profile_fields.rb
49
+ - lib/circle_so/resources/posts.rb
50
+ - lib/circle_so/resources/profile_fields.rb
51
+ - lib/circle_so/resources/search.rb
52
+ - lib/circle_so/resources/space_group_members.rb
53
+ - lib/circle_so/resources/space_groups.rb
54
+ - lib/circle_so/resources/space_members.rb
55
+ - lib/circle_so/resources/spaces.rb
56
+ - lib/circle_so/resources/tagged_members.rb
57
+ - lib/circle_so/resources/topics.rb
58
+ - lib/circle_so/token.rb
59
+ - lib/circle_so/version.rb
60
+ homepage: https://github.com/linyiru/circle-so
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/linyiru/circle-so
65
+ source_code_uri: https://github.com/linyiru/circle-so
66
+ changelog_uri: https://github.com/linyiru/circle-so/blob/main/CHANGELOG.md
67
+ bug_tracker_uri: https://github.com/linyiru/circle-so/issues
68
+ rubygems_mfa_required: 'true'
69
+ rdoc_options: []
70
+ require_paths:
71
+ - lib
72
+ required_ruby_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: 3.1.0
77
+ required_rubygems_version: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubygems_version: 3.6.9
84
+ specification_version: 4
85
+ summary: Ruby client for the Circle.so Admin API v2
86
+ test_files: []