basecamp-sdk 0.2.1
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.
- checksums.yaml +7 -0
- data/.rubocop.yml +14 -0
- data/.yardopts +6 -0
- data/README.md +293 -0
- data/Rakefile +26 -0
- data/basecamp-sdk.gemspec +46 -0
- data/lib/basecamp/auth_strategy.rb +38 -0
- data/lib/basecamp/chain_hooks.rb +45 -0
- data/lib/basecamp/client.rb +428 -0
- data/lib/basecamp/config.rb +143 -0
- data/lib/basecamp/errors.rb +289 -0
- data/lib/basecamp/generated/metadata.json +2281 -0
- data/lib/basecamp/generated/services/attachments_service.rb +24 -0
- data/lib/basecamp/generated/services/boosts_service.rb +70 -0
- data/lib/basecamp/generated/services/campfires_service.rb +122 -0
- data/lib/basecamp/generated/services/card_columns_service.rb +103 -0
- data/lib/basecamp/generated/services/card_steps_service.rb +57 -0
- data/lib/basecamp/generated/services/card_tables_service.rb +20 -0
- data/lib/basecamp/generated/services/cards_service.rb +66 -0
- data/lib/basecamp/generated/services/checkins_service.rb +157 -0
- data/lib/basecamp/generated/services/client_approvals_service.rb +28 -0
- data/lib/basecamp/generated/services/client_correspondences_service.rb +28 -0
- data/lib/basecamp/generated/services/client_replies_service.rb +30 -0
- data/lib/basecamp/generated/services/client_visibility_service.rb +21 -0
- data/lib/basecamp/generated/services/comments_service.rb +49 -0
- data/lib/basecamp/generated/services/documents_service.rb +52 -0
- data/lib/basecamp/generated/services/events_service.rb +20 -0
- data/lib/basecamp/generated/services/forwards_service.rb +67 -0
- data/lib/basecamp/generated/services/lineup_service.rb +44 -0
- data/lib/basecamp/generated/services/message_boards_service.rb +20 -0
- data/lib/basecamp/generated/services/message_types_service.rb +59 -0
- data/lib/basecamp/generated/services/messages_service.rb +75 -0
- data/lib/basecamp/generated/services/people_service.rb +73 -0
- data/lib/basecamp/generated/services/projects_service.rb +63 -0
- data/lib/basecamp/generated/services/recordings_service.rb +64 -0
- data/lib/basecamp/generated/services/reports_service.rb +56 -0
- data/lib/basecamp/generated/services/schedules_service.rb +92 -0
- data/lib/basecamp/generated/services/search_service.rb +31 -0
- data/lib/basecamp/generated/services/subscriptions_service.rb +50 -0
- data/lib/basecamp/generated/services/templates_service.rb +82 -0
- data/lib/basecamp/generated/services/timeline_service.rb +20 -0
- data/lib/basecamp/generated/services/timesheets_service.rb +81 -0
- data/lib/basecamp/generated/services/todolist_groups_service.rb +41 -0
- data/lib/basecamp/generated/services/todolists_service.rb +53 -0
- data/lib/basecamp/generated/services/todos_service.rb +106 -0
- data/lib/basecamp/generated/services/todosets_service.rb +20 -0
- data/lib/basecamp/generated/services/tools_service.rb +80 -0
- data/lib/basecamp/generated/services/uploads_service.rb +61 -0
- data/lib/basecamp/generated/services/vaults_service.rb +49 -0
- data/lib/basecamp/generated/services/webhooks_service.rb +63 -0
- data/lib/basecamp/generated/types.rb +3196 -0
- data/lib/basecamp/hooks.rb +70 -0
- data/lib/basecamp/http.rb +440 -0
- data/lib/basecamp/logger_hooks.rb +46 -0
- data/lib/basecamp/noop_hooks.rb +9 -0
- data/lib/basecamp/oauth/discovery.rb +123 -0
- data/lib/basecamp/oauth/errors.rb +35 -0
- data/lib/basecamp/oauth/exchange.rb +291 -0
- data/lib/basecamp/oauth/pkce.rb +68 -0
- data/lib/basecamp/oauth/types.rb +133 -0
- data/lib/basecamp/oauth.rb +56 -0
- data/lib/basecamp/oauth_token_provider.rb +108 -0
- data/lib/basecamp/operation_info.rb +17 -0
- data/lib/basecamp/request_info.rb +10 -0
- data/lib/basecamp/request_result.rb +14 -0
- data/lib/basecamp/security.rb +112 -0
- data/lib/basecamp/services/attachments_service.rb +33 -0
- data/lib/basecamp/services/authorization_service.rb +47 -0
- data/lib/basecamp/services/base_service.rb +146 -0
- data/lib/basecamp/services/campfires_service.rb +141 -0
- data/lib/basecamp/services/card_columns_service.rb +106 -0
- data/lib/basecamp/services/card_steps_service.rb +86 -0
- data/lib/basecamp/services/card_tables_service.rb +23 -0
- data/lib/basecamp/services/cards_service.rb +93 -0
- data/lib/basecamp/services/checkins_service.rb +127 -0
- data/lib/basecamp/services/client_approvals_service.rb +33 -0
- data/lib/basecamp/services/client_correspondences_service.rb +33 -0
- data/lib/basecamp/services/client_replies_service.rb +35 -0
- data/lib/basecamp/services/comments_service.rb +63 -0
- data/lib/basecamp/services/documents_service.rb +74 -0
- data/lib/basecamp/services/events_service.rb +27 -0
- data/lib/basecamp/services/forwards_service.rb +80 -0
- data/lib/basecamp/services/lineup_service.rb +67 -0
- data/lib/basecamp/services/message_boards_service.rb +24 -0
- data/lib/basecamp/services/message_types_service.rb +79 -0
- data/lib/basecamp/services/messages_service.rb +133 -0
- data/lib/basecamp/services/people_service.rb +73 -0
- data/lib/basecamp/services/projects_service.rb +67 -0
- data/lib/basecamp/services/recordings_service.rb +127 -0
- data/lib/basecamp/services/reports_service.rb +80 -0
- data/lib/basecamp/services/schedules_service.rb +156 -0
- data/lib/basecamp/services/search_service.rb +36 -0
- data/lib/basecamp/services/subscriptions_service.rb +67 -0
- data/lib/basecamp/services/templates_service.rb +96 -0
- data/lib/basecamp/services/timeline_service.rb +62 -0
- data/lib/basecamp/services/timesheet_service.rb +68 -0
- data/lib/basecamp/services/todolist_groups_service.rb +100 -0
- data/lib/basecamp/services/todolists_service.rb +104 -0
- data/lib/basecamp/services/todos_service.rb +156 -0
- data/lib/basecamp/services/todosets_service.rb +23 -0
- data/lib/basecamp/services/tools_service.rb +89 -0
- data/lib/basecamp/services/uploads_service.rb +84 -0
- data/lib/basecamp/services/vaults_service.rb +84 -0
- data/lib/basecamp/services/webhooks_service.rb +88 -0
- data/lib/basecamp/static_token_provider.rb +24 -0
- data/lib/basecamp/token_provider.rb +42 -0
- data/lib/basecamp/version.rb +6 -0
- data/lib/basecamp/webhooks/event.rb +52 -0
- data/lib/basecamp/webhooks/rack_middleware.rb +49 -0
- data/lib/basecamp/webhooks/receiver.rb +161 -0
- data/lib/basecamp/webhooks/verify.rb +36 -0
- data/lib/basecamp.rb +107 -0
- data/scripts/generate-metadata.rb +106 -0
- data/scripts/generate-services.rb +778 -0
- data/scripts/generate-types.rb +191 -0
- metadata +316 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Information about a service operation for observability hooks.
|
|
5
|
+
OperationInfo = Data.define(:service, :operation, :resource_type, :is_mutation, :project_id, :resource_id) do
|
|
6
|
+
def initialize(service:, operation:, resource_type: nil, is_mutation: false, project_id: nil, resource_id: nil)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Result information for completed service operations.
|
|
12
|
+
OperationResult = Data.define(:duration_ms, :error) do
|
|
13
|
+
def initialize(duration_ms: 0, error: nil)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
# Result information for completed HTTP requests.
|
|
5
|
+
RequestResult = Data.define(:status_code, :duration, :error, :retry_after, :from_cache) do
|
|
6
|
+
def initialize(status_code: nil, duration: 0.0, error: nil, retry_after: nil, from_cache: false)
|
|
7
|
+
super
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def success?
|
|
11
|
+
status_code && status_code >= 200 && status_code < 300
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Basecamp
|
|
6
|
+
# Security helpers for URL validation, message truncation, and header redaction.
|
|
7
|
+
# Used across the SDK to enforce HTTPS, prevent SSRF, and protect sensitive data.
|
|
8
|
+
module Security
|
|
9
|
+
MAX_ERROR_MESSAGE_BYTES = 500
|
|
10
|
+
MAX_RESPONSE_BODY_BYTES = 50 * 1024 * 1024 # 50 MB
|
|
11
|
+
MAX_ERROR_BODY_BYTES = 1 * 1024 * 1024 # 1 MB
|
|
12
|
+
|
|
13
|
+
def self.truncate(str, max = MAX_ERROR_MESSAGE_BYTES)
|
|
14
|
+
return str if str.nil? || str.bytesize <= max
|
|
15
|
+
|
|
16
|
+
max <= 3 ? str.byteslice(0, max) : str.byteslice(0, max - 3) + "..."
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.require_https!(url, label = "URL")
|
|
20
|
+
uri = URI.parse(url.to_s)
|
|
21
|
+
raise UsageError.new("#{label} must use HTTPS: #{url}") unless uri.scheme&.downcase == "https"
|
|
22
|
+
rescue URI::InvalidURIError
|
|
23
|
+
raise UsageError.new("Invalid #{label}: #{url}")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.same_origin?(a, b)
|
|
27
|
+
ua = URI.parse(a)
|
|
28
|
+
ub = URI.parse(b)
|
|
29
|
+
return false if ua.scheme.nil? || ub.scheme.nil?
|
|
30
|
+
|
|
31
|
+
ua.scheme.downcase == ub.scheme.downcase &&
|
|
32
|
+
normalize_host(ua) == normalize_host(ub)
|
|
33
|
+
rescue URI::InvalidURIError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.resolve_url(base, target)
|
|
38
|
+
URI.join(base, target).to_s
|
|
39
|
+
rescue URI::InvalidURIError
|
|
40
|
+
target
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.normalize_host(uri)
|
|
44
|
+
host = uri.host&.downcase
|
|
45
|
+
port = uri.port
|
|
46
|
+
return host if port.nil?
|
|
47
|
+
return host if uri.scheme&.downcase == "https" && port == 443
|
|
48
|
+
return host if uri.scheme&.downcase == "http" && port == 80
|
|
49
|
+
|
|
50
|
+
"#{host}:#{port}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.check_body_size!(body, max, label = "Response")
|
|
54
|
+
return if body.nil?
|
|
55
|
+
|
|
56
|
+
if body.bytesize > max
|
|
57
|
+
raise Basecamp::APIError.new(
|
|
58
|
+
"#{label} body too large (#{body.bytesize} bytes, max #{max})"
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.localhost?(url)
|
|
64
|
+
uri = URI.parse(url.to_s)
|
|
65
|
+
host = uri.host&.downcase
|
|
66
|
+
return false if host.nil?
|
|
67
|
+
|
|
68
|
+
host == "localhost" ||
|
|
69
|
+
host == "127.0.0.1" ||
|
|
70
|
+
host == "::1" ||
|
|
71
|
+
host == "[::1]" ||
|
|
72
|
+
host.end_with?(".localhost")
|
|
73
|
+
rescue URI::InvalidURIError
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.require_https_unless_localhost!(url, label = "URL")
|
|
78
|
+
return if localhost?(url)
|
|
79
|
+
|
|
80
|
+
require_https!(url, label)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Headers that contain sensitive values and should be redacted.
|
|
84
|
+
SENSITIVE_HEADERS = %w[
|
|
85
|
+
authorization
|
|
86
|
+
cookie
|
|
87
|
+
set-cookie
|
|
88
|
+
x-csrf-token
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# Returns a copy of the headers with sensitive values replaced by "[REDACTED]".
|
|
92
|
+
#
|
|
93
|
+
# This is useful for safely logging HTTP requests and responses without
|
|
94
|
+
# exposing tokens, cookies, or other credentials.
|
|
95
|
+
#
|
|
96
|
+
# @param headers [Hash] the headers hash (case-insensitive keys)
|
|
97
|
+
# @return [Hash] a new hash with sensitive values redacted
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# headers = { "Authorization" => "Bearer token", "Content-Type" => "application/json" }
|
|
101
|
+
# safe = Basecamp::Security.redact_headers(headers)
|
|
102
|
+
# # => { "Authorization" => "[REDACTED]", "Content-Type" => "application/json" }
|
|
103
|
+
#
|
|
104
|
+
def self.redact_headers(headers)
|
|
105
|
+
result = {}
|
|
106
|
+
headers.each do |key, value|
|
|
107
|
+
result[key] = SENSITIVE_HEADERS.include?(key.to_s.downcase) ? "[REDACTED]" : value
|
|
108
|
+
end
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Basecamp
|
|
6
|
+
module Services
|
|
7
|
+
# Service for attachment operations.
|
|
8
|
+
#
|
|
9
|
+
# Attachments are used to upload files that can be embedded in rich text
|
|
10
|
+
# content like messages, comments, and documents. After uploading, you
|
|
11
|
+
# receive an attachable_sgid that can be used to embed the file in HTML.
|
|
12
|
+
#
|
|
13
|
+
# @example Upload a file
|
|
14
|
+
# attachment = account.attachments.create(
|
|
15
|
+
# filename: "report.pdf",
|
|
16
|
+
# content_type: "application/pdf",
|
|
17
|
+
# data: file_content
|
|
18
|
+
# )
|
|
19
|
+
# # Use in HTML: <bc-attachment sgid="#{attachment["attachable_sgid"]}"></bc-attachment>
|
|
20
|
+
class AttachmentsService < BaseService
|
|
21
|
+
# Creates an attachment by uploading a file.
|
|
22
|
+
# Returns an attachable_sgid for embedding the file in rich text content.
|
|
23
|
+
#
|
|
24
|
+
# @param filename [String] filename for the uploaded file
|
|
25
|
+
# @param content_type [String] MIME content type (e.g., "image/png", "application/pdf")
|
|
26
|
+
# @param data [String] file data
|
|
27
|
+
# @return [Hash] attachment response with attachable_sgid
|
|
28
|
+
def create(filename:, content_type:, data:)
|
|
29
|
+
http_post_raw("/attachments.json?name=#{URI.encode_www_form_component(filename)}", body: data, content_type: content_type).json
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Service for authorization operations.
|
|
6
|
+
# This is the only service that doesn't require an account context.
|
|
7
|
+
#
|
|
8
|
+
# @example Get authorization info
|
|
9
|
+
# auth = client.authorization.get
|
|
10
|
+
# puts "Identity: #{auth["identity"]["email_address"]}"
|
|
11
|
+
# auth["accounts"].each do |account|
|
|
12
|
+
# puts "Account: #{account["name"]} (#{account["id"]})"
|
|
13
|
+
# end
|
|
14
|
+
class AuthorizationService < BaseService
|
|
15
|
+
# Fallback Launchpad endpoint for authorization
|
|
16
|
+
LAUNCHPAD_AUTHORIZATION_URL = "https://launchpad.37signals.com/authorization.json"
|
|
17
|
+
|
|
18
|
+
# Gets authorization information for the current user.
|
|
19
|
+
#
|
|
20
|
+
# Attempts to use the authorization endpoint discovered via OAuth discovery
|
|
21
|
+
# on the configured base URL. Falls back to Launchpad if discovery fails.
|
|
22
|
+
#
|
|
23
|
+
# Returns the authenticated user's identity and list of accounts
|
|
24
|
+
# they have access to.
|
|
25
|
+
#
|
|
26
|
+
# @return [Hash] authorization info with :identity and :accounts
|
|
27
|
+
# @see https://github.com/basecamp/bc3-api/blob/master/sections/authentication.md
|
|
28
|
+
def get
|
|
29
|
+
url = discover_authorization_url
|
|
30
|
+
response = http.get_absolute(url)
|
|
31
|
+
response.json
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def discover_authorization_url
|
|
37
|
+
# Try OAuth discovery on the configured base URL
|
|
38
|
+
config = Oauth.discover(http.base_url)
|
|
39
|
+
# Use issuer as base for authorization.json
|
|
40
|
+
"#{config.issuer.chomp("/")}/authorization.json"
|
|
41
|
+
rescue Oauth::OAuthError
|
|
42
|
+
# Fall back to Launchpad
|
|
43
|
+
LAUNCHPAD_AUTHORIZATION_URL
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi/escape"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Basecamp
|
|
7
|
+
module Services
|
|
8
|
+
# Base service class for Basecamp API services.
|
|
9
|
+
#
|
|
10
|
+
# Provides shared functionality for all service classes including:
|
|
11
|
+
# - HTTP method delegation (http_get, http_post, etc.)
|
|
12
|
+
# - Path building helpers
|
|
13
|
+
# - Pagination support
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# class TodosService < BaseService
|
|
17
|
+
# def list(project_id:, todolist_id:)
|
|
18
|
+
# paginate(bucket_path(project_id, "/todolists/#{todolist_id}/todos.json"))
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
class BaseService
|
|
22
|
+
# @return [String] the account ID for API requests
|
|
23
|
+
attr_reader :account_id
|
|
24
|
+
|
|
25
|
+
# @param client [Object] the parent client (AccountClient or Client)
|
|
26
|
+
def initialize(client)
|
|
27
|
+
@client = client
|
|
28
|
+
@account_id = client.account_id
|
|
29
|
+
@hooks = client.hooks
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
protected
|
|
33
|
+
|
|
34
|
+
# Wraps a service operation with hooks for observability.
|
|
35
|
+
# @param service [String] service name (e.g., "projects")
|
|
36
|
+
# @param operation [String] operation name (e.g., "list")
|
|
37
|
+
# @param resource_type [String, nil] resource type (e.g., "Project")
|
|
38
|
+
# @param is_mutation [Boolean] whether this is a write operation
|
|
39
|
+
# @param project_id [Integer, String, nil] project/bucket ID
|
|
40
|
+
# @param resource_id [Integer, String, nil] resource ID
|
|
41
|
+
# @yield the operation to execute
|
|
42
|
+
# @return the result of the block
|
|
43
|
+
def with_operation(service:, operation:, resource_type: nil, is_mutation: false, project_id: nil, resource_id: nil)
|
|
44
|
+
info = OperationInfo.new(
|
|
45
|
+
service: service, operation: operation, resource_type: resource_type,
|
|
46
|
+
is_mutation: is_mutation, project_id: project_id, resource_id: resource_id
|
|
47
|
+
)
|
|
48
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
49
|
+
safe_hook { @hooks.on_operation_start(info) }
|
|
50
|
+
result = yield
|
|
51
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
52
|
+
safe_hook { @hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: nil)) }
|
|
53
|
+
result
|
|
54
|
+
rescue => e
|
|
55
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
56
|
+
safe_hook { @hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: e)) }
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Wraps a lazy Enumerator so operation hooks fire around actual iteration,
|
|
61
|
+
# not at enumerator creation time. Hooks fire when the consumer begins
|
|
62
|
+
# iterating (.each, .to_a, .first, etc.) and end fires via ensure when
|
|
63
|
+
# iteration completes, errors, or is cut short by break/take.
|
|
64
|
+
def wrap_paginated(service:, operation:, is_mutation: false, project_id: nil, resource_id: nil)
|
|
65
|
+
info = OperationInfo.new(
|
|
66
|
+
service: service, operation: operation,
|
|
67
|
+
is_mutation: is_mutation, project_id: project_id, resource_id: resource_id
|
|
68
|
+
)
|
|
69
|
+
enum = yield
|
|
70
|
+
|
|
71
|
+
hooks = @hooks
|
|
72
|
+
Enumerator.new do |yielder|
|
|
73
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
74
|
+
error = nil
|
|
75
|
+
begin
|
|
76
|
+
safe_hook { hooks.on_operation_start(info) }
|
|
77
|
+
enum.each { |item| yielder.yield(item) }
|
|
78
|
+
rescue => e
|
|
79
|
+
error = e
|
|
80
|
+
raise
|
|
81
|
+
ensure
|
|
82
|
+
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round
|
|
83
|
+
safe_hook { hooks.on_operation_end(info, OperationResult.new(duration_ms: duration, error: error)) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Invoke a hook callback, swallowing exceptions so hooks never break SDK behavior.
|
|
89
|
+
def safe_hook
|
|
90
|
+
yield
|
|
91
|
+
rescue => e
|
|
92
|
+
warn "Basecamp hook error: #{e.class}: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [HTTP] the HTTP client for direct access
|
|
96
|
+
def http
|
|
97
|
+
@client.http
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Helper to remove nil values from a hash.
|
|
101
|
+
# @param hash [Hash] the input hash
|
|
102
|
+
# @return [Hash] hash with nil values removed
|
|
103
|
+
def compact_params(**kwargs)
|
|
104
|
+
kwargs.compact
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Build a bucket (project) path.
|
|
108
|
+
# @param project_id [Integer, String] the project/bucket ID
|
|
109
|
+
# @param path [String] the path suffix
|
|
110
|
+
# @return [String] the full bucket path
|
|
111
|
+
def bucket_path(project_id, path)
|
|
112
|
+
"/buckets/#{project_id}#{path}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Delegate HTTP methods to the client with http_ prefix to avoid conflicts
|
|
116
|
+
# with service method names (e.g., service.get vs http_get)
|
|
117
|
+
# @!method http_get(path, params: {})
|
|
118
|
+
# @see AccountClient#get
|
|
119
|
+
# @!method http_post(path, body: nil)
|
|
120
|
+
# @see AccountClient#post
|
|
121
|
+
# @!method http_put(path, body: nil)
|
|
122
|
+
# @see AccountClient#put
|
|
123
|
+
# @!method http_delete(path)
|
|
124
|
+
# @see AccountClient#delete
|
|
125
|
+
# @!method http_post_raw(path, body:, content_type:)
|
|
126
|
+
# @see AccountClient#post_raw
|
|
127
|
+
# @!method paginate(path, params: {}, &block)
|
|
128
|
+
# @see AccountClient#paginate
|
|
129
|
+
%i[get post put delete post_raw].each do |method|
|
|
130
|
+
define_method(:"http_#{method}") do |*args, **kwargs, &block|
|
|
131
|
+
@client.public_send(method, *args, **kwargs, &block)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Paginate doesn't conflict with service methods, keep as-is
|
|
136
|
+
def paginate(...)
|
|
137
|
+
@client.paginate(...)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Paginate extracting items from a specific key (for object responses)
|
|
141
|
+
def paginate_key(...)
|
|
142
|
+
@client.paginate_key(...)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Service for campfire (chat) operations.
|
|
6
|
+
#
|
|
7
|
+
# Campfires are real-time chat rooms within Basecamp projects.
|
|
8
|
+
# They contain lines (messages) and can have chatbot integrations.
|
|
9
|
+
#
|
|
10
|
+
# @example List all campfires
|
|
11
|
+
# account.campfires.list.each do |campfire|
|
|
12
|
+
# puts campfire["title"]
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# @example Send a message
|
|
16
|
+
# line = account.campfires.create_line(
|
|
17
|
+
# project_id: 123,
|
|
18
|
+
# campfire_id: 456,
|
|
19
|
+
# content: "Hello team!"
|
|
20
|
+
# )
|
|
21
|
+
class CampfiresService < BaseService
|
|
22
|
+
# Lists all campfires across the account.
|
|
23
|
+
#
|
|
24
|
+
# @return [Enumerator<Hash>] campfires
|
|
25
|
+
def list
|
|
26
|
+
paginate("/chats.json")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gets a specific campfire.
|
|
30
|
+
#
|
|
31
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
32
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
33
|
+
# @return [Hash] campfire data
|
|
34
|
+
def get(project_id:, campfire_id:)
|
|
35
|
+
http_get(bucket_path(project_id, "/chats/#{campfire_id}.json")).json
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Lists all lines (messages) in a campfire.
|
|
39
|
+
#
|
|
40
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
41
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
42
|
+
# @return [Enumerator<Hash>] campfire lines
|
|
43
|
+
def list_lines(project_id:, campfire_id:)
|
|
44
|
+
paginate(bucket_path(project_id, "/chats/#{campfire_id}/lines.json"))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Gets a specific line (message) from a campfire.
|
|
48
|
+
#
|
|
49
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
50
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
51
|
+
# @param line_id [Integer, String] line ID
|
|
52
|
+
# @return [Hash] campfire line
|
|
53
|
+
def get_line(project_id:, campfire_id:, line_id:)
|
|
54
|
+
http_get(bucket_path(project_id, "/chats/#{campfire_id}/lines/#{line_id}.json")).json
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Creates a new line (message) in a campfire.
|
|
58
|
+
#
|
|
59
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
60
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
61
|
+
# @param content [String] plain text message content
|
|
62
|
+
# @return [Hash] created line
|
|
63
|
+
def create_line(project_id:, campfire_id:, content:)
|
|
64
|
+
body = { content: content }
|
|
65
|
+
http_post(bucket_path(project_id, "/chats/#{campfire_id}/lines.json"), body: body).json
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Deletes a line (message) from a campfire.
|
|
69
|
+
#
|
|
70
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
71
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
72
|
+
# @param line_id [Integer, String] line ID
|
|
73
|
+
# @return [void]
|
|
74
|
+
def delete_line(project_id:, campfire_id:, line_id:)
|
|
75
|
+
http_delete(bucket_path(project_id, "/chats/#{campfire_id}/lines/#{line_id}.json"))
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Lists all chatbots for a campfire.
|
|
80
|
+
#
|
|
81
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
82
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
83
|
+
# @return [Enumerator<Hash>] chatbots
|
|
84
|
+
def list_chatbots(project_id:, campfire_id:)
|
|
85
|
+
paginate(bucket_path(project_id, "/chats/#{campfire_id}/integrations.json"))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Gets a specific chatbot.
|
|
89
|
+
#
|
|
90
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
91
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
92
|
+
# @param chatbot_id [Integer, String] chatbot ID
|
|
93
|
+
# @return [Hash] chatbot data
|
|
94
|
+
def get_chatbot(project_id:, campfire_id:, chatbot_id:)
|
|
95
|
+
http_get(bucket_path(project_id, "/chats/#{campfire_id}/integrations/#{chatbot_id}.json")).json
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Creates a new chatbot for a campfire.
|
|
99
|
+
#
|
|
100
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
101
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
102
|
+
# @param service_name [String] chatbot name (no spaces, emoji, or non-word characters)
|
|
103
|
+
# @param command_url [String, nil] HTTPS URL for bot callbacks
|
|
104
|
+
# @return [Hash] created chatbot with lines_url for posting
|
|
105
|
+
def create_chatbot(project_id:, campfire_id:, service_name:, command_url: nil)
|
|
106
|
+
body = compact_params(
|
|
107
|
+
service_name: service_name,
|
|
108
|
+
command_url: command_url
|
|
109
|
+
)
|
|
110
|
+
http_post(bucket_path(project_id, "/chats/#{campfire_id}/integrations.json"), body: body).json
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Updates an existing chatbot.
|
|
114
|
+
#
|
|
115
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
116
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
117
|
+
# @param chatbot_id [Integer, String] chatbot ID
|
|
118
|
+
# @param service_name [String] new chatbot name
|
|
119
|
+
# @param command_url [String, nil] new callback URL
|
|
120
|
+
# @return [Hash] updated chatbot
|
|
121
|
+
def update_chatbot(project_id:, campfire_id:, chatbot_id:, service_name:, command_url: nil)
|
|
122
|
+
body = compact_params(
|
|
123
|
+
service_name: service_name,
|
|
124
|
+
command_url: command_url
|
|
125
|
+
)
|
|
126
|
+
http_put(bucket_path(project_id, "/chats/#{campfire_id}/integrations/#{chatbot_id}.json"), body: body).json
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Deletes a chatbot.
|
|
130
|
+
#
|
|
131
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
132
|
+
# @param campfire_id [Integer, String] campfire ID
|
|
133
|
+
# @param chatbot_id [Integer, String] chatbot ID
|
|
134
|
+
# @return [void]
|
|
135
|
+
def delete_chatbot(project_id:, campfire_id:, chatbot_id:)
|
|
136
|
+
http_delete(bucket_path(project_id, "/chats/#{campfire_id}/integrations/#{chatbot_id}.json"))
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Basecamp
|
|
4
|
+
module Services
|
|
5
|
+
# Service for card column operations.
|
|
6
|
+
#
|
|
7
|
+
# Columns are lists within a card table that contain cards.
|
|
8
|
+
#
|
|
9
|
+
# @example Create a column
|
|
10
|
+
# column = account.card_columns.create(
|
|
11
|
+
# project_id: 123,
|
|
12
|
+
# card_table_id: 456,
|
|
13
|
+
# title: "In Review"
|
|
14
|
+
# )
|
|
15
|
+
#
|
|
16
|
+
# @example Set column color
|
|
17
|
+
# account.card_columns.set_color(project_id: 123, column_id: 456, color: "blue")
|
|
18
|
+
class CardColumnsService < BaseService
|
|
19
|
+
# Gets a column by ID.
|
|
20
|
+
#
|
|
21
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
22
|
+
# @param column_id [Integer, String] column ID
|
|
23
|
+
# @return [Hash] column data
|
|
24
|
+
def get(project_id:, column_id:)
|
|
25
|
+
http_get(bucket_path(project_id, "/card_tables/columns/#{column_id}.json")).json
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates a new column in a card table.
|
|
29
|
+
#
|
|
30
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
31
|
+
# @param card_table_id [Integer, String] card table ID
|
|
32
|
+
# @param title [String] column title
|
|
33
|
+
# @param description [String, nil] column description
|
|
34
|
+
# @return [Hash] created column
|
|
35
|
+
def create(project_id:, card_table_id:, title:, description: nil)
|
|
36
|
+
body = compact_params(
|
|
37
|
+
title: title,
|
|
38
|
+
description: description
|
|
39
|
+
)
|
|
40
|
+
http_post(bucket_path(project_id, "/card_tables/#{card_table_id}/columns.json"), body: body).json
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Updates an existing column.
|
|
44
|
+
#
|
|
45
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
46
|
+
# @param column_id [Integer, String] column ID
|
|
47
|
+
# @param title [String, nil] new title
|
|
48
|
+
# @param description [String, nil] new description
|
|
49
|
+
# @return [Hash] updated column
|
|
50
|
+
def update(project_id:, column_id:, title: nil, description: nil)
|
|
51
|
+
body = compact_params(
|
|
52
|
+
title: title,
|
|
53
|
+
description: description
|
|
54
|
+
)
|
|
55
|
+
http_put(bucket_path(project_id, "/card_tables/columns/#{column_id}.json"), body: body).json
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Moves a column within a card table.
|
|
59
|
+
#
|
|
60
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
61
|
+
# @param card_table_id [Integer, String] card table ID
|
|
62
|
+
# @param source_id [Integer, String] column ID to move
|
|
63
|
+
# @param target_id [Integer, String] column ID to move relative to
|
|
64
|
+
# @param position [Integer, nil] position relative to target
|
|
65
|
+
# @return [void]
|
|
66
|
+
def move(project_id:, card_table_id:, source_id:, target_id:, position: nil)
|
|
67
|
+
body = compact_params(
|
|
68
|
+
source_id: source_id,
|
|
69
|
+
target_id: target_id,
|
|
70
|
+
position: position
|
|
71
|
+
)
|
|
72
|
+
http_post(bucket_path(project_id, "/card_tables/#{card_table_id}/moves.json"), body: body)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sets the color of a column.
|
|
77
|
+
#
|
|
78
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
79
|
+
# @param column_id [Integer, String] column ID
|
|
80
|
+
# @param color [String] color name (white, red, orange, yellow, green, blue, aqua, purple, gray, pink, brown)
|
|
81
|
+
# @return [Hash] updated column
|
|
82
|
+
def set_color(project_id:, column_id:, color:)
|
|
83
|
+
http_put(bucket_path(project_id, "/card_tables/columns/#{column_id}/color.json"),
|
|
84
|
+
body: { color: color }).json
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Adds an on-hold section to a column.
|
|
88
|
+
#
|
|
89
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
90
|
+
# @param column_id [Integer, String] column ID
|
|
91
|
+
# @return [Hash] updated column
|
|
92
|
+
def enable_on_hold(project_id:, column_id:)
|
|
93
|
+
http_post(bucket_path(project_id, "/card_tables/columns/#{column_id}/on_hold.json")).json
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Removes the on-hold section from a column.
|
|
97
|
+
#
|
|
98
|
+
# @param project_id [Integer, String] project (bucket) ID
|
|
99
|
+
# @param column_id [Integer, String] column ID
|
|
100
|
+
# @return [Hash] updated column
|
|
101
|
+
def disable_on_hold(project_id:, column_id:)
|
|
102
|
+
http_delete(bucket_path(project_id, "/card_tables/columns/#{column_id}/on_hold.json")).json
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|