chat_sdk-x 0.8.0 → 0.9.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: 0cc78776461860e0f234747c168059eba9e1af62401e97752f9b0e9c9c60fe62
4
- data.tar.gz: c158cf5c06608dc12c5b238ec0653c6e25a62dc77e50dba0cc07ca6ba4a4bda2
3
+ metadata.gz: 63e9c01ba58ff3e2d9578ad50800e304742b77bc6c0ecb6fd981bef912d98858
4
+ data.tar.gz: 331da1b589a8eb037f5c74a78a41b32fe531602b377e70b340b1f8741a1df6d5
5
5
  SHA512:
6
- metadata.gz: 559ab2ada28ef4a7dfeea3224dc4c2fd2645352d902f1d52758a2f2eb3d07b3fe723de14f6f4e7c229758f5024a1bdfc8f83e0661c402b88679a41ebee4c037e
7
- data.tar.gz: bfc0a4e918126c31492a0e3ad54ffdea98792b3452934b222a052439f6a9ac71856127366ce1bd92708b412466c3e332144c13c0dd5380aef7c9663c39da1861
6
+ metadata.gz: 4fee2efa74b95e3832a2e1471649b3a1ade9ab02f6460867f1bd769c72ccd5e56733ea37489398ae359870654de92cbe4f5753b145c5a62f71a33129193feb4a
7
+ data.tar.gz: b35c9f9f56f881c2f7f298746dca29a0cf50fba7f262516c80f37c2505a135510d9e88b6d32ea06fa794436ae8ce82b7c11ddcf46350cc0a1c43751a10a0856f
@@ -13,15 +13,37 @@ module ChatSDK
13
13
 
14
14
  attr_reader :client
15
15
 
16
- def initialize(access_token: nil, consumer_secret: nil, user_id: nil)
16
+ def initialize(access_token: nil, consumer_secret: nil, user_id: nil,
17
+ client_id: nil, client_secret: nil, refresh_token: nil)
17
18
  @access_token = access_token || ENV["X_ACCESS_TOKEN"]
18
19
  @consumer_secret = consumer_secret || ENV["X_CONSUMER_SECRET"]
19
20
  @user_id = user_id || ENV["X_USER_ID"]
20
-
21
- raise ChatSDK::ConfigurationError, "X access_token required" unless @access_token
21
+ @client_id = client_id || ENV["X_CLIENT_ID"]
22
+ @client_secret = client_secret || ENV["X_CLIENT_SECRET"]
23
+ @refresh_token = refresh_token || ENV["X_REFRESH_TOKEN"]
24
+
25
+ # Validate: need either access_token (static) or client_id + refresh_token (managed OAuth)
26
+ has_oauth = @client_id && @refresh_token
27
+ if !@access_token && !has_oauth
28
+ raise ChatSDK::ConfigurationError,
29
+ "X requires either access_token or client_id + refresh_token"
30
+ end
22
31
  raise ChatSDK::ConfigurationError, "X consumer_secret required" unless @consumer_secret
23
32
 
24
- @client = ApiClient.new(@access_token)
33
+ @client = ApiClient.new(
34
+ access_token: @access_token,
35
+ client_id: @client_id,
36
+ client_secret: @client_secret,
37
+ refresh_token: @refresh_token
38
+ )
39
+ end
40
+
41
+ # Inject state store after initialization (e.g., from Chat instance).
42
+ # Loads any previously persisted tokens so auth survives restarts.
43
+ def set_state(state)
44
+ @state = state
45
+ @client.state = state
46
+ @client.load_stored_token if @client_id
25
47
  end
26
48
 
27
49
  def name
@@ -6,11 +6,23 @@ module ChatSDK
6
6
  module X
7
7
  class ApiClient < ChatSDK::ApiClient::Base
8
8
  BASE_URL = "https://api.x.com"
9
+ TOKEN_URL = "https://api.x.com/2/oauth2/token"
10
+ TOKEN_EXPIRY_MARGIN = 60 # seconds before expiry to trigger proactive refresh
9
11
 
10
- def initialize(access_token)
12
+ def initialize(access_token: nil, client_id: nil, client_secret: nil,
13
+ refresh_token: nil, state: nil)
11
14
  @access_token = access_token
15
+ @client_id = client_id
16
+ @client_secret = client_secret
17
+ @refresh_token = refresh_token
18
+ @state = state
19
+ @token_expires_at = nil
20
+ @refresh_mutex = Mutex.new
12
21
  end
13
22
 
23
+ # Allow post-init state injection (called by adapter's set_state)
24
+ attr_writer :state
25
+
14
26
  def create_tweet(text:, reply_to: nil, media_ids: nil)
15
27
  body = {"text" => text}
16
28
  body["reply"] = {"in_reply_to_tweet_id" => reply_to} if reply_to
@@ -73,8 +85,26 @@ module ChatSDK
73
85
  request(:get, "/2/dm_conversations/with/#{participant_id}/dm_events?#{query}")
74
86
  end
75
87
 
88
+ # Load stored token data from state store (survives restarts)
89
+ def load_stored_token
90
+ return unless @state && @client_id
91
+
92
+ stored = @state.get(token_state_key)
93
+ return unless stored.is_a?(Hash)
94
+
95
+ @access_token = stored["access_token"] if stored["access_token"]
96
+ @refresh_token = stored["refresh_token"] if stored["refresh_token"]
97
+ @token_expires_at = Time.at(stored["expires_at"]) if stored["expires_at"]
98
+ @connection = nil # rebuild connection with loaded token
99
+ end
100
+
76
101
  private
77
102
 
103
+ def request(method, path, body = nil)
104
+ ensure_valid_token
105
+ super
106
+ end
107
+
78
108
  def base_url
79
109
  BASE_URL
80
110
  end
@@ -101,6 +131,78 @@ module ChatSDK
101
131
  seconds = reset.to_i - Time.now.to_i
102
132
  [seconds, 1].max
103
133
  end
134
+
135
+ # OAuth2 token refresh support
136
+
137
+ def ensure_valid_token
138
+ return unless @client_id # static token mode — no refresh
139
+ return if @token_expires_at && Time.now < @token_expires_at
140
+
141
+ @refresh_mutex.synchronize do
142
+ # Double-check after acquiring lock (another thread may have refreshed)
143
+ return if @token_expires_at && Time.now < @token_expires_at
144
+
145
+ refresh_access_token
146
+ end
147
+ end
148
+
149
+ def refresh_access_token
150
+ params = {
151
+ "grant_type" => "refresh_token",
152
+ "refresh_token" => @refresh_token
153
+ }
154
+ # Public client: client_id in body; Confidential client: Basic auth header
155
+ params["client_id"] = @client_id unless @client_secret
156
+
157
+ headers = {"Content-Type" => "application/x-www-form-urlencoded"}
158
+ if @client_secret
159
+ encoded = Base64.strict_encode64("#{@client_id}:#{@client_secret}")
160
+ headers["Authorization"] = "Basic #{encoded}"
161
+ end
162
+
163
+ response = Faraday.post(TOKEN_URL) do |req|
164
+ req.headers = headers
165
+ req.body = URI.encode_www_form(params)
166
+ end
167
+
168
+ unless response.success?
169
+ data = begin
170
+ JSON.parse(response.body)
171
+ rescue JSON::ParserError
172
+ {}
173
+ end
174
+ raise ChatSDK::PlatformError.new(
175
+ "X token refresh failed: #{data["error_description"] || response.status}",
176
+ status: response.status,
177
+ body: response.body,
178
+ adapter_name: :x
179
+ )
180
+ end
181
+
182
+ data = JSON.parse(response.body)
183
+ @access_token = data["access_token"]
184
+ @refresh_token = data["refresh_token"] # ROTATED — must persist
185
+ @token_expires_at = Time.now + (data["expires_in"]&.to_i || 7200) - TOKEN_EXPIRY_MARGIN
186
+
187
+ # Rebuild Faraday connection with new token
188
+ @connection = nil
189
+
190
+ persist_token
191
+ end
192
+
193
+ def token_state_key
194
+ "x:oauth:#{@client_id}"
195
+ end
196
+
197
+ def persist_token
198
+ return unless @state
199
+
200
+ @state.set(token_state_key, {
201
+ "access_token" => @access_token,
202
+ "refresh_token" => @refresh_token,
203
+ "expires_at" => @token_expires_at&.to_f
204
+ })
205
+ end
104
206
  end
105
207
  end
106
208
  end
@@ -8,37 +8,12 @@ module ChatSDK
8
8
  platform_text.to_s
9
9
  end
10
10
 
11
- # Markdown → X (strip all formatting to plain text)
12
11
  def from_markdown(markdown)
13
12
  text = markdown.to_s
14
13
  return "" if text.empty?
15
14
 
16
15
  strip_markdown(text)
17
16
  end
18
-
19
- private
20
-
21
- def strip_markdown(text)
22
- result = text
23
-
24
- # Remove fenced code blocks but keep content
25
- result = result.gsub(/```\w*\n?([\s\S]*?)```/, '\1')
26
-
27
- # Remove inline code markers
28
- result = result.gsub(/`([^`]+)`/, '\1')
29
-
30
- # Convert links: [text](url) → text (url)
31
- result = result.gsub(/\[([^\]]+)\]\(([^)]+)\)/, '\1 (\2)')
32
-
33
- # Remove bold markers: **text** → text
34
- result = result.gsub(/\*\*(.+?)\*\*/m, '\1')
35
-
36
- # Remove italic markers: *text* → text
37
- result = result.gsub(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/m, '\1')
38
-
39
- # Remove strikethrough markers: ~~text~~ → text
40
- result.gsub(/~~(.+?)~~/m, '\1')
41
- end
42
17
  end
43
18
  end
44
19
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_sdk-x
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau