basecamp-sdk 0.25.0 → 0.27.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7713550a3bc2d6b5dd732e3bdadb679d5016316e9f4004677c028a30daa65371
4
- data.tar.gz: 71b74a2b499c484f245669d9b317df4f826c799f2d2bbda0cce140a75b22884f
3
+ metadata.gz: 69a94db94f0ff533b6160519f9ed6e41089ef70639d36e7042668f8ba5c819e9
4
+ data.tar.gz: a19a2cf4cff6180e1671e826bc6a9e93794576c05af574c0cd0ab64296a361e7
5
5
  SHA512:
6
- metadata.gz: a5f430f32c8ef3e13f0b3690ec31223bc942f06bc907708fe33fbfa84b99a86dcfe56a2734ef0e8224f5cfdfdc41ce4c28703e8321c6ba88330bba4bedf775f0
7
- data.tar.gz: 834ea9c748c9d64ce67089e765875bf8c6c56a52ce0d895629b1c02f4d6e2acbdcb63b566a563c19823ce1c3199016b02fba3c92136bbbd5cb67cf6d950a0744
6
+ metadata.gz: bec94397b02003677dd55ce96cb2786c4d0f8b502097f564086187c74ba767e6191b0104ca9d67eaece577664646a1f6828e877b9407e2f6c7613ad9c8c36c5d
7
+ data.tar.gz: 5523532f3233eecefc27ec7ca6c0048513f66fee6b53986be5458c120f83f75be65509de87eaeb22e84f2814356f30758d64fb1df392cc63dc5a76bb2bb7d8b5
data/README.md CHANGED
@@ -348,6 +348,8 @@ The SDK provides the full account-scoped service set documented in SPEC §5. The
348
348
  | `card_columns` | Card table columns |
349
349
  | `card_steps` | Card workflow steps |
350
350
  | `subtasks` | Subtasks (checklist items) on to-dos and cards |
351
+ | `pings` | Pings (direct and group chats) |
352
+ | `embeds` | Embeds for rich text (create, get) |
351
353
  | `wormholes` | Card table wormholes (cross-project moves) |
352
354
  | `lineup` | Card lineup view |
353
355
  | `tools` | Project dock tools |
@@ -8,12 +8,15 @@ module Basecamp
8
8
  # Candidates come from two sources, tried in order and each cached {TTL}
9
9
  # seconds:
10
10
  #
11
- # 1. The bucket's project dock, whose "chat" tool is the project's Campfire:
12
- # one project read per bucket, and the answer for every line posted in a
13
- # project. Cached per bucket.
11
+ # 1. The bucket's own Campfire. For a project, its dock's "chat" tool: one
12
+ # project read per bucket, and the answer for every line posted in a
13
+ # project. For a bucket that is not a project, its Ping's Campfire, since
14
+ # a Ping's bucket type is "Circle". Cached per bucket.
14
15
  # 2. The account-wide Campfire listing (BC3 has no per-bucket one), filtered
15
- # to the bucket, for buckets that are not projects or whose dock did not
16
- # hold the line. Cached per account, so a burst of lines costs one listing.
16
+ # to the bucket, for buckets that are neither or whose own Campfire did not
17
+ # hold the line. BC3 lists only dock-positioned Campfires there, so a
18
+ # ping's is never in it. Cached per account, so a burst of lines costs one
19
+ # listing.
17
20
  #
18
21
  # This class holds the caching only; the reads themselves are passed in as
19
22
  # blocks by +RecordingsExtensions+, so every request still goes through a
@@ -28,7 +31,7 @@ module Basecamp
28
31
  # buckets and accounts consulted within the last TTL, and never more than the
29
32
  # bound, whatever the Client has seen.
30
33
  class CampfireIndex
31
- # How long a cached discovery source — a bucket's project dock, the
34
+ # How long a cached discovery source — a bucket's own Campfire, the
32
35
  # account's Campfire listing — is reused before it is read again.
33
36
  TTL = 600.0
34
37
 
@@ -40,12 +43,13 @@ module Basecamp
40
43
 
41
44
  # Caps the account-wide Campfire listing the fallback source reads. A
42
45
  # listing that overflows it is not cached and the call reports
43
- # {CampfireDiscoveryIncompleteError}: the dock covers every project, so the
44
- # listing only ever serves the leftover, and an account with more Campfires
45
- # than this should not pay a full walk per TTL for it.
46
+ # {CampfireDiscoveryIncompleteError}: the bucket's own Campfire covers every
47
+ # project and every ping, so the listing only ever serves the leftover, and
48
+ # an account with more Campfires than this should not pay a full walk per
49
+ # TTL for it.
46
50
  MAX_LISTING = 1000
47
51
 
48
- # Bounds each cache's entry count. The dock cache holds one snapshot per
52
+ # Bounds each cache's entry count. The bucket cache holds one snapshot per
49
53
  # bucket consulted within the TTL: a connector listening across every
50
54
  # project an agent can see touches hundreds of buckets, not thousands, and a
51
55
  # snapshot is a handful of ids, so this is generous headroom at a few
@@ -75,12 +79,13 @@ module Basecamp
75
79
  @listings = TTLCache.new(ttl: TTL, floor: MIN_REFRESH, max_items: MAX_ITEMS, clock: clock)
76
80
  end
77
81
 
78
- # The Campfire ids a bucket's project dock names.
82
+ # The Campfire ids a bucket's own source names: a project's dock, or a
83
+ # Ping's room.
79
84
  #
80
85
  # @param account_id [String]
81
86
  # @param bucket_id [Integer]
82
87
  # @param refresh [Boolean] re-read a cached snapshot older than {MIN_REFRESH}
83
- # @yieldreturn [Array<Integer>] the dock's Campfire ids
88
+ # @yieldreturn [Array<Integer>] the bucket's own Campfire ids
84
89
  # @return [SourceRead]
85
90
  def dock_campfires(account_id:, bucket_id:, refresh: false, &load)
86
91
  hit = @docks.get([ account_id, bucket_id ], refresh: refresh, &load)
@@ -92,7 +97,8 @@ module Basecamp
92
97
  #
93
98
  # It exists so a caller can consult what the source already holds before
94
99
  # deciding whether to pay for the listing fetch — the expensive, slow
95
- # request, which the dock (including its refresh) should get to pre-empt.
100
+ # request, which the bucket's own source (including its refresh) should get
101
+ # to pre-empt.
96
102
  #
97
103
  # @param account_id [String]
98
104
  # @param bucket_id [Integer]
@@ -612,6 +612,15 @@ module Basecamp
612
612
  service(:subtasks) { Services::SubtasksService.new(self) }
613
613
  end
614
614
 
615
+ # @return [Services::PingsService]
616
+ def pings
617
+ service(:pings) { Services::PingsService.new(self) }
618
+ end
619
+
620
+ def embeds
621
+ service(:embeds) { Services::EmbedsService.new(self) }
622
+ end
623
+
615
624
  # @return [Services::AccountService]
616
625
  def account
617
626
  service(:account) { Services::AccountService.new(self) }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://basecamp.com/schemas/sdk-metadata.json",
3
3
  "version": "1.0.0",
4
- "generated": "2026-10-15T16:04:42Z",
4
+ "generated": "2026-10-20T15:47:49Z",
5
5
  "operations": {
6
6
  "GetAccount": {
7
7
  "retry": {
@@ -78,6 +78,20 @@
78
78
  ]
79
79
  }
80
80
  },
81
+ "UpdateBoost": {
82
+ "retry": {
83
+ "maxAttempts": 3,
84
+ "baseDelayMs": 1000,
85
+ "backoff": "exponential",
86
+ "retryOn": [
87
+ 429,
88
+ 503
89
+ ]
90
+ },
91
+ "idempotent": {
92
+ "natural": true
93
+ }
94
+ },
81
95
  "DeleteBoost": {
82
96
  "retry": {
83
97
  "maxAttempts": 3,
@@ -919,6 +933,28 @@
919
933
  "maxPageSize": 50
920
934
  }
921
935
  },
936
+ "ListPings": {
937
+ "retry": {
938
+ "maxAttempts": 3,
939
+ "baseDelayMs": 1000,
940
+ "backoff": "exponential",
941
+ "retryOn": [
942
+ 429,
943
+ 503
944
+ ]
945
+ }
946
+ },
947
+ "CreatePing": {
948
+ "retry": {
949
+ "maxAttempts": 2,
950
+ "baseDelayMs": 1000,
951
+ "backoff": "exponential",
952
+ "retryOn": [
953
+ 429,
954
+ 503
955
+ ]
956
+ }
957
+ },
922
958
  "ListPingablePeople": {
923
959
  "retry": {
924
960
  "maxAttempts": 3,
@@ -935,6 +971,17 @@
935
971
  "maxPageSize": 50
936
972
  }
937
973
  },
974
+ "GetPing": {
975
+ "retry": {
976
+ "maxAttempts": 3,
977
+ "baseDelayMs": 1000,
978
+ "backoff": "exponential",
979
+ "retryOn": [
980
+ 429,
981
+ 503
982
+ ]
983
+ }
984
+ },
938
985
  "GetClientApproval": {
939
986
  "retry": {
940
987
  "maxAttempts": 3,
@@ -1087,6 +1134,30 @@
1087
1134
  "natural": true
1088
1135
  }
1089
1136
  },
1137
+ "CreateEmbed": {
1138
+ "retry": {
1139
+ "maxAttempts": 3,
1140
+ "baseDelayMs": 1000,
1141
+ "backoff": "exponential",
1142
+ "retryOn": [
1143
+ 503
1144
+ ]
1145
+ },
1146
+ "idempotent": {
1147
+ "natural": true
1148
+ }
1149
+ },
1150
+ "GetEmbed": {
1151
+ "retry": {
1152
+ "maxAttempts": 3,
1153
+ "baseDelayMs": 1000,
1154
+ "backoff": "exponential",
1155
+ "retryOn": [
1156
+ 429,
1157
+ 503
1158
+ ]
1159
+ }
1160
+ },
1090
1161
  "PollEvents": {
1091
1162
  "retry": {
1092
1163
  "maxAttempts": 3,
@@ -3730,6 +3801,9 @@
3730
3801
  "CreateMessage": [
3731
3802
  "creator"
3732
3803
  ],
3804
+ "CreatePing": [
3805
+ "people.[]"
3806
+ ],
3733
3807
  "CreateQuestion": [
3734
3808
  "creator"
3735
3809
  ],
@@ -4077,6 +4151,9 @@
4077
4151
  "events.[].creator",
4078
4152
  "person"
4079
4153
  ],
4154
+ "GetPing": [
4155
+ "people.[]"
4156
+ ],
4080
4157
  "GetProgressReport": [
4081
4158
  "[].attachments.[].creator",
4082
4159
  "[].creator"
@@ -4251,6 +4328,9 @@
4251
4328
  "ListPingablePeople": [
4252
4329
  "[]"
4253
4330
  ],
4331
+ "ListPings": [
4332
+ "[].people.[]"
4333
+ ],
4254
4334
  "ListProjectPeople": [
4255
4335
  "[]"
4256
4336
  ],
@@ -4340,6 +4420,9 @@
4340
4420
  "Subscribe": [
4341
4421
  "subscribers.[]"
4342
4422
  ],
4423
+ "UpdateBoost": [
4424
+ "booster"
4425
+ ],
4343
4426
  "UpdateCard": [
4344
4427
  "assignees.[]",
4345
4428
  "completer",
@@ -162,6 +162,10 @@ module Basecamp
162
162
  kwargs.compact
163
163
  end
164
164
 
165
+ def path_segment(value)
166
+ CGI.escapeURIComponent(value.to_s)
167
+ end
168
+
165
169
  # Like compact_params, but also drops empty arrays. For query filters an
166
170
  # empty array means "no filter" and must be omitted entirely: Faraday's
167
171
  # nested encoder would otherwise emit a bare `bucket_ids[]`, which Rails
@@ -16,6 +16,16 @@ module Basecamp
16
16
  end
17
17
  end
18
18
 
19
+ # Replace the content of a boost. Only the boost's creator may update it: an
20
+ # @param boost_id [Integer] boost id ID
21
+ # @param content [String] content
22
+ # @return [Hash] response data
23
+ def update_boost(boost_id:, content:)
24
+ with_operation(service: "boosts", operation: "update_boost", is_mutation: true, resource_id: boost_id) do
25
+ http_put("/boosts/#{boost_id}", body: compact_params(content: content)).json(operation: "UpdateBoost")
26
+ end
27
+ end
28
+
19
29
  # Delete a boost
20
30
  # @param boost_id [Integer] boost id ID
21
31
  # @return [void]
@@ -29,9 +29,10 @@ module Basecamp
29
29
  end
30
30
  end
31
31
 
32
- # Move a card to a different column
32
+ # Move a card to any column in its project, including on another card table there, or through a linked wormhole to another project (queued: the card gets a new id, and position, if sent, must still be positive but doesn't set placement).
33
33
  # @param card_id [Integer] card id ID
34
- # @param column_id [Integer] column id
34
+ # @param column_id [Integer] Any column in the card's project, on this or another card table, or a
35
+ # wormhole id to send the card to another project.
35
36
  # @param position [Integer, nil] 1-indexed position within the destination column. Defaults to 1 (top).
36
37
  # @return [void]
37
38
  def move(card_id:, column_id:, position: nil)
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Service for Embeds operations
6
+ #
7
+ # @generated from OpenAPI spec
8
+ class EmbedsService < BaseService
9
+
10
+ # Create an embed for a URL, or return the one Basecamp already has for it.
11
+ # @param url [String] The URL of the content to embed.
12
+ # @return [Hash] response data
13
+ def create(url:)
14
+ with_operation(service: "embeds", operation: "create", is_mutation: true) do
15
+ http_post("/embeds.json", body: compact_params(url: url)).json(operation: "CreateEmbed")
16
+ end
17
+ end
18
+
19
+ # Get an embed by its sgid.
20
+ # @param sgid [String] An embed's signed global id, an opaque string.
21
+ # @return [Hash] response data
22
+ def get(sgid:)
23
+ with_operation(service: "embeds", operation: "get", is_mutation: false) do
24
+ http_get("/embeds/#{path_segment(sgid)}", operation: "GetEmbed").json(operation: "GetEmbed")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Basecamp
4
+ module Services
5
+ # Service for Pings operations
6
+ #
7
+ # @generated from OpenAPI spec
8
+ class PingsService < BaseService
9
+
10
+ # Get Pings: the caller's active Pings that have a line in them, most
11
+ # @return [Array<Hash>] response data
12
+ def list()
13
+ with_operation(service: "pings", operation: "list", is_mutation: false) do
14
+ http_get("/circles.json", operation: "ListPings").json(operation: "ListPings")
15
+ end
16
+ end
17
+
18
+ # Create a Ping with people, or return the one that already holds exactly
19
+ # @param user_ids [Array] Person ids of the people to ping. People the caller cannot reach are
20
+ # dropped.
21
+ # @param new_group [Boolean, nil] Create a new group even when a Ping with exactly these people exists.
22
+ # @return [Hash] response data
23
+ def create(user_ids:, new_group: nil)
24
+ with_operation(service: "pings", operation: "create", is_mutation: true) do
25
+ http_post("/circles.json", body: compact_params(user_ids: user_ids, new_group: new_group)).json(operation: "CreatePing")
26
+ end
27
+ end
28
+
29
+ # Get a Ping by its id, which is its bucket's id.
30
+ # @param ping_id [Integer] A Ping's id is the id of the bucket that holds it (the bucket type is
31
+ # "Circle", which is also the wire path), so the `bucket.id` a Ping's
32
+ # recordings carry is the id GetPing takes.
33
+ # @return [Hash] response data
34
+ def get(ping_id:)
35
+ with_operation(service: "pings", operation: "get", is_mutation: false, resource_id: ping_id) do
36
+ http_get("/circles/#{ping_id}", operation: "GetPing").json(operation: "GetPing")
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -72,7 +72,7 @@ module Basecamp
72
72
  # @return [Hash] response data
73
73
  def get_entry_occurrence(entry_id:, date:)
74
74
  with_operation(service: "schedules", operation: "get_entry_occurrence", is_mutation: false, resource_id: entry_id) do
75
- http_get("/schedule_entries/#{entry_id}/occurrences/#{date}", operation: "GetScheduleEntryOccurrence").json(operation: "GetScheduleEntryOccurrence")
75
+ http_get("/schedule_entries/#{entry_id}/occurrences/#{path_segment(date)}", operation: "GetScheduleEntryOccurrence").json(operation: "GetScheduleEntryOccurrence")
76
76
  end
77
77
  end
78
78
 
@@ -7,7 +7,7 @@ module Basecamp
7
7
  # @generated from OpenAPI spec
8
8
  class WormholesService < BaseService
9
9
 
10
- # Update a wormhole's destination column
10
+ # Point a card table's wormhole, its shortcut for sending cards to another card table, at a different destination column
11
11
  # @param bucket_id [Integer] bucket id ID
12
12
  # @param wormhole_id [Integer] wormhole id ID
13
13
  # @param destination_recording_id [Integer] Id of the new destination column (on another accessible card table).
@@ -18,7 +18,7 @@ module Basecamp
18
18
  end
19
19
  end
20
20
 
21
- # Delete a wormhole
21
+ # Permanently delete a card table's wormhole, the shortcut that sends cards to another card table.
22
22
  # @param bucket_id [Integer] bucket id ID
23
23
  # @param wormhole_id [Integer] wormhole id ID
24
24
  # @return [void]
@@ -29,7 +29,7 @@ module Basecamp
29
29
  end
30
30
  end
31
31
 
32
- # Create a wormhole linking this card table to a column on another card table.
32
+ # Add a wormhole to a card table: a standing shortcut that sends cards to a column on another card table, usually in another project.
33
33
  # @param bucket_id [Integer] bucket id ID
34
34
  # @param card_table_id [Integer] card table id ID
35
35
  # @param destination_recording_id [Integer] Id of the destination column (on another accessible card table) to link to.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Auto-generated from OpenAPI spec. Do not edit manually.
4
- # Generated: 2026-10-15T16:04:42Z
4
+ # Generated: 2026-10-20T15:47:49Z
5
5
 
6
6
  require "json"
7
7
  require "time"
@@ -1685,6 +1685,43 @@ module Basecamp
1685
1685
  end
1686
1686
  end
1687
1687
 
1688
+ # Embed
1689
+ class Embed
1690
+ include TypeHelpers
1691
+ attr_accessor :sgid, :status, :url, :content, :content_type, :image_url, :type
1692
+
1693
+ # @return [Array<Symbol>]
1694
+ def self.required_fields
1695
+ %i[sgid status url].freeze
1696
+ end
1697
+
1698
+ def initialize(data = {})
1699
+ @sgid = data["sgid"]
1700
+ @status = data["status"]
1701
+ @url = data["url"]
1702
+ @content = data["content"]
1703
+ @content_type = data["content_type"]
1704
+ @image_url = data["image_url"]
1705
+ @type = data["type"]
1706
+ end
1707
+
1708
+ def to_h
1709
+ {
1710
+ "sgid" => @sgid,
1711
+ "status" => @status,
1712
+ "url" => @url,
1713
+ "content" => @content,
1714
+ "content_type" => @content_type,
1715
+ "image_url" => @image_url,
1716
+ "type" => @type,
1717
+ }.compact
1718
+ end
1719
+
1720
+ def to_json(*args)
1721
+ to_h.to_json(*args)
1722
+ end
1723
+ end
1724
+
1688
1725
  # Event
1689
1726
  class Event
1690
1727
  include TypeHelpers
@@ -3208,6 +3245,84 @@ module Basecamp
3208
3245
  end
3209
3246
  end
3210
3247
 
3248
+ # Ping
3249
+ class Ping
3250
+ include TypeHelpers
3251
+ attr_accessor :app_url, :avatar_url, :bookmark_url, :created_at, :id, :links, :named, :people, :room_url, :title, :updated_at, :url, :edit_title_url, :image_url
3252
+
3253
+ # @return [Array<Symbol>]
3254
+ def self.required_fields
3255
+ %i[app_url avatar_url bookmark_url created_at id links named people room_url title updated_at url].freeze
3256
+ end
3257
+
3258
+ def initialize(data = {})
3259
+ @app_url = data["app_url"]
3260
+ @avatar_url = data["avatar_url"]
3261
+ @bookmark_url = data["bookmark_url"]
3262
+ @created_at = parse_datetime(data["created_at"])
3263
+ @id = parse_integer(data["id"])
3264
+ @links = parse_type(data["links"], "PingLinks")
3265
+ @named = parse_boolean(data["named"])
3266
+ @people = parse_array(data["people"], "Person")
3267
+ @room_url = data["room_url"]
3268
+ @title = data["title"]
3269
+ @updated_at = parse_datetime(data["updated_at"])
3270
+ @url = data["url"]
3271
+ @edit_title_url = data["edit_title_url"]
3272
+ @image_url = data["image_url"]
3273
+ end
3274
+
3275
+ def to_h
3276
+ {
3277
+ "app_url" => @app_url,
3278
+ "avatar_url" => @avatar_url,
3279
+ "bookmark_url" => @bookmark_url,
3280
+ "created_at" => @created_at,
3281
+ "id" => @id,
3282
+ "links" => @links,
3283
+ "named" => @named,
3284
+ "people" => @people,
3285
+ "room_url" => @room_url,
3286
+ "title" => @title,
3287
+ "updated_at" => @updated_at,
3288
+ "url" => @url,
3289
+ "edit_title_url" => @edit_title_url,
3290
+ "image_url" => @image_url,
3291
+ }.compact
3292
+ end
3293
+
3294
+ def to_json(*args)
3295
+ to_h.to_json(*args)
3296
+ end
3297
+ end
3298
+
3299
+ # PingLinks
3300
+ class PingLinks
3301
+ include TypeHelpers
3302
+ attr_accessor :files, :lines
3303
+
3304
+ # @return [Array<Symbol>]
3305
+ def self.required_fields
3306
+ %i[files lines].freeze
3307
+ end
3308
+
3309
+ def initialize(data = {})
3310
+ @files = data["files"]
3311
+ @lines = data["lines"]
3312
+ end
3313
+
3314
+ def to_h
3315
+ {
3316
+ "files" => @files,
3317
+ "lines" => @lines,
3318
+ }.compact
3319
+ end
3320
+
3321
+ def to_json(*args)
3322
+ to_h.to_json(*args)
3323
+ end
3324
+ end
3325
+
3211
3326
  # Preferences
3212
3327
  class Preferences
3213
3328
  include TypeHelpers
@@ -49,6 +49,8 @@ module Basecamp
49
49
  "The recording summary reads this field to decide what the recording is and which " \
50
50
  "project it belongs to, so a value of the wrong type cannot be used."
51
51
 
52
+ ROOM_URL_CAMPFIRE = %r{/chats/([0-9]+)(?:\.json)?\z}
53
+
52
54
  DISCOVERY_HINT =
53
55
  "The recording summary reads this field to decide which Campfires a chat line could " \
54
56
  "be in, so a value of the wrong type cannot be used."
@@ -133,10 +135,10 @@ module Basecamp
133
135
  RICH_TEXT_CHAT_LINE_TYPES = [ "Chat::Lines::RichText", "Chat::Lines::Integration" ].freeze
134
136
 
135
137
  # Bounds how many Campfires one {#summarize} call tries, across both
136
- # discovery sources and the refresh. A project has one Campfire and a
137
- # handful of pings; a bucket past this bound is not a shape BC3 produces,
138
- # and the call reports {Basecamp::CampfireDiscoveryIncompleteError} rather
139
- # than calling the rest absent.
138
+ # discovery sources and the refresh. A project or a Ping has one
139
+ # Campfire; a bucket past this bound is not a shape BC3 produces, and the
140
+ # call reports {Basecamp::CampfireDiscoveryIncompleteError} rather than
141
+ # calling the rest absent.
140
142
  MAX_CAMPFIRE_CANDIDATES = 50
141
143
 
142
144
  # Where #project leaves the validated bucket id for the cross-bucket
@@ -1029,14 +1031,14 @@ module Basecamp
1029
1031
  account_id = @client.account_id
1030
1032
  search = ChatLineSearch.new(campfires: @client.campfires, line_id: line_id)
1031
1033
 
1032
- # Pass 1: what the sources already hold — the dock (read if it must be),
1033
- # then the listing only if it is cached. A listing fetch is the
1034
- # expensive, slow request, and it is not made until the dock — including
1035
- # its refresh — has had its say, so a listing that is down, over its cap,
1036
- # or stalled never stands between a project's line and the one project
1037
- # read that finds it.
1034
+ # Pass 1: what the sources already hold — the bucket's own Campfire
1035
+ # (read if it must be), then the listing only if it is cached. A listing
1036
+ # fetch is the expensive, slow request, and it is not made until the
1037
+ # bucket's own source — including its refresh — has had its say, so a
1038
+ # listing that is down, over its cap, or stalled never stands between a
1039
+ # project's line and the one project read that finds it.
1038
1040
  dock = index.dock_campfires(account_id: account_id, bucket_id: bucket_id) do
1039
- dock_campfire_ids(bucket_id)
1041
+ bucket_campfire_ids(bucket_id)
1040
1042
  end
1041
1043
  found = search.try(dock.ids)
1042
1044
  return found if found
@@ -1048,7 +1050,7 @@ module Basecamp
1048
1050
  return found if found
1049
1051
  end
1050
1052
 
1051
- # Pass 2: re-read the dock if it was served from cache (the floor may
1053
+ # Pass 2: re-read the bucket's own source if it was served from cache (the floor may
1052
1054
  # decline), then fetch or refresh the listing. Whatever comes back is the
1053
1055
  # current snapshot of that source, whoever loaded it — another caller may
1054
1056
  # have populated or refreshed it in the meantime — so it always replaces
@@ -1071,7 +1073,7 @@ module Basecamp
1071
1073
 
1072
1074
  if search.budget_left? && dock.cached?
1073
1075
  again = index.dock_campfires(account_id: account_id, bucket_id: bucket_id, refresh: true) do
1074
- dock_campfire_ids(bucket_id)
1076
+ bucket_campfire_ids(bucket_id)
1075
1077
  end
1076
1078
  refreshed = true if again.fetched > dock.fetched || !again.cached?
1077
1079
  dock = again
@@ -1119,17 +1121,52 @@ module Basecamp
1119
1121
  )
1120
1122
  end
1121
1123
 
1122
- # The Campfire ids a bucket's project dock names. A bucket that is not a
1123
- # project (a 404 on the project read) has none; any other failure of the
1124
- # read is raised.
1125
- def dock_campfire_ids(bucket_id)
1124
+ # The bucket's own Campfire ids: a project's dock "chat" tools or, when
1125
+ # the project read 404s, its Ping's Campfire. A bucket that is neither
1126
+ # has none; any other failure of either read is raised, so a 403 "Pings
1127
+ # have been disabled" is a failed read and never an unresolved line.
1128
+ def bucket_campfire_ids(bucket_id)
1126
1129
  project = begin
1127
1130
  @client.projects.get(project_id: bucket_id)
1131
+ rescue NotFoundError
1132
+ return ping_campfire_ids(bucket_id)
1133
+ end
1134
+
1135
+ dock_campfire_ids(read_record(project))
1136
+ end
1137
+
1138
+ # The one Campfire a Ping holds, named by its +room_url+. A Ping whose
1139
+ # room_url names no Campfire has none.
1140
+ def ping_campfire_ids(bucket_id)
1141
+ ping = begin
1142
+ @client.pings.get(ping_id: bucket_id)
1128
1143
  rescue NotFoundError
1129
1144
  return []
1130
1145
  end
1131
1146
 
1132
- project = read_record(project)
1147
+ room_url = read_record(ping)["room_url"]
1148
+ return [] if room_url.nil?
1149
+
1150
+ unless room_url.is_a?(String)
1151
+ raise malformed_response("the Ping's \"room_url\" is #{MergeSafe.describe(room_url)}, not a string",
1152
+ hint: DISCOVERY_HINT)
1153
+ end
1154
+
1155
+ id = campfire_id_from_room_url(room_url)
1156
+ id ? [ id ] : []
1157
+ end
1158
+
1159
+ # Strict: the path must end in +/chats/{digits}+, optionally +.json+, so a
1160
+ # Campfire's lines, a to-do or a garbled URL name no Campfire.
1161
+ def campfire_id_from_room_url(room_url)
1162
+ match = ROOM_URL_CAMPFIRE.match(room_url.split(/[?#]/, 2).first)
1163
+ return nil if match.nil?
1164
+
1165
+ id = Ids.bounded_decimal(match[1], signed: false)
1166
+ id.is_a?(Integer) && id.positive? ? id : nil
1167
+ end
1168
+
1169
+ def dock_campfire_ids(project)
1133
1170
  dock = project["dock"]
1134
1171
  # An absent dock is genuinely none — a project need not have one, and the
1135
1172
  # reference reads its zero value. Anything else that is not an array is a
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Basecamp
4
- VERSION = "0.25.0"
5
- API_VERSION = "2026-09-15"
4
+ VERSION = "0.27.0"
5
+ API_VERSION = "2026-10-19"
6
6
  end
@@ -83,6 +83,8 @@ class ServiceGenerator
83
83
  'ClientFeatures' => 'ClientFeatures',
84
84
  'Boosts' => 'Boosts',
85
85
  'Subtasks' => 'Subtasks',
86
+ 'Pings' => 'Pings',
87
+ 'Embeds' => 'Embeds',
86
88
  'Untagged' => 'Miscellaneous'
87
89
  }.freeze
88
90
 
@@ -363,8 +365,8 @@ class ServiceGenerator
363
365
  lineupmarker clientapproval clientapprovals clientcorrespondence
364
366
  clientcorrespondences clientreply clientreplies forwardreply
365
367
  forwardreplies campfireline campfirelines todolistgroup todolistgroups
366
- todolistorgroup uploadversions hillchart hillcharts subtask subtasks
367
- wormhole wormholes
368
+ todolistorgroup uploadversions hillchart hillcharts subtask subtasks ping pings
369
+ wormhole wormholes embed embeds
368
370
  ].freeze
369
371
 
370
372
  def initialize(openapi_path)
@@ -570,7 +572,7 @@ class ServiceGenerator
570
572
  operation_id: operation_id,
571
573
  method_name: method_name,
572
574
  http_method: http_method,
573
- path: convert_path(path),
575
+ path: convert_path(path, path_params),
574
576
  description: description,
575
577
  path_params: path_params,
576
578
  query_params: query_params,
@@ -678,14 +680,15 @@ class ServiceGenerator
678
680
  SIMPLE_RESOURCES.include?(resource.downcase.gsub('_', ''))
679
681
  end
680
682
 
681
- def convert_path(path)
683
+ def convert_path(path, path_params = [])
682
684
  # Remove /{accountId} prefix
683
685
  path = path.sub(%r{^/\{accountId\}}, '')
686
+ string_params = path_params.select { |p| p[:type] == 'String' }.map { |p| p[:name] }
684
687
  # Convert {camelCaseParam} to #{snake_case_param}
685
688
  path.gsub(/\{(\w+)\}/) do |_match|
686
689
  param = ::Regexp.last_match(1)
687
690
  snake_param = to_snake_case(param)
688
- "\#{#{snake_param}}"
691
+ string_params.include?(param) ? "\#{path_segment(#{snake_param})}" : "\#{#{snake_param}}"
689
692
  end
690
693
  end
691
694
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: basecamp-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Basecamp
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-15 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -202,6 +202,7 @@ files:
202
202
  - lib/basecamp/generated/services/comments_service.rb
203
203
  - lib/basecamp/generated/services/documents_service.rb
204
204
  - lib/basecamp/generated/services/drafts_service.rb
205
+ - lib/basecamp/generated/services/embeds_service.rb
205
206
  - lib/basecamp/generated/services/event_feed_service.rb
206
207
  - lib/basecamp/generated/services/events_service.rb
207
208
  - lib/basecamp/generated/services/everything_service.rb
@@ -218,6 +219,7 @@ files:
218
219
  - lib/basecamp/generated/services/my_notes_service.rb
219
220
  - lib/basecamp/generated/services/my_notifications_service.rb
220
221
  - lib/basecamp/generated/services/people_service.rb
222
+ - lib/basecamp/generated/services/pings_service.rb
221
223
  - lib/basecamp/generated/services/projects_service.rb
222
224
  - lib/basecamp/generated/services/recordings_service.rb
223
225
  - lib/basecamp/generated/services/reports_service.rb