chat_sdk-x 0.8.1 → 1.0.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 +4 -4
- data/lib/chat_sdk/x/adapter.rb +26 -4
- data/lib/chat_sdk/x/api_client.rb +103 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 257778cbe6b641d2d3183654dd217cc8e64fd0b82285192b25261f6b4cddf50f
|
|
4
|
+
data.tar.gz: 331da1b589a8eb037f5c74a78a41b32fe531602b377e70b340b1f8741a1df6d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fba627f9a528ba56e8510aca008cd6500f9fd4c305853844e2c19c53c8d01d21449ceb4ff4c38a6383c0ebb92047716722bc6d0a5ec04d7ddc102340108d255c
|
|
7
|
+
data.tar.gz: b35c9f9f56f881c2f7f298746dca29a0cf50fba7f262516c80f37c2505a135510d9e88b6d32ea06fa794436ae8ce82b7c11ddcf46350cc0a1c43751a10a0856f
|
data/lib/chat_sdk/x/adapter.rb
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
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.
|
|
4
|
+
version: 1.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Quentin Rousseau
|
|
@@ -13,14 +13,14 @@ dependencies:
|
|
|
13
13
|
name: chat_sdk
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '0.1'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '0.1'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|