Class: ChatSDK::X::Adapter

Inherits:
Adapter::Base
  • Object
show all
Includes:
Adapter::MediaTypes
Defined in:
lib/chat_sdk/x/adapter.rb

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(access_token: nil, consumer_secret: nil, user_id: nil, client_id: nil, client_secret: nil, refresh_token: nil) ⇒ Adapter

Returns a new instance of Adapter.

Raises:

  • (ChatSDK::ConfigurationError)


16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
# File 'lib/chat_sdk/x/adapter.rb', line 16

def initialize(access_token: nil, consumer_secret: nil, user_id: nil,
  client_id: nil, client_secret: nil, refresh_token: nil)
  @access_token = access_token || ENV["X_ACCESS_TOKEN"]
  @consumer_secret = consumer_secret || ENV["X_CONSUMER_SECRET"]
  @user_id = user_id || ENV["X_USER_ID"]
  @client_id = client_id || ENV["X_CLIENT_ID"]
  @client_secret = client_secret || ENV["X_CLIENT_SECRET"]
  @refresh_token = refresh_token || ENV["X_REFRESH_TOKEN"]

  # Validate: need either access_token (static) or client_id + refresh_token (managed OAuth)
  has_oauth = @client_id && @refresh_token
  if !@access_token && !has_oauth
    raise ChatSDK::ConfigurationError,
      "X requires either access_token or client_id + refresh_token"
  end
  raise ChatSDK::ConfigurationError, "X consumer_secret required" unless @consumer_secret

  @client = ApiClient.new(
    access_token: @access_token,
    client_id: @client_id,
    client_secret: @client_secret,
    refresh_token: @refresh_token
  )
end

Instance Attribute Details

#clientObject (readonly)

Returns the value of attribute client.



14
15
16
# File 'lib/chat_sdk/x/adapter.rb', line 14

def client
  @client
end

Instance Method Details

#ack_response(rack_request) ⇒ Object



73
74
75
76
77
78
79
80
81
82
# File 'lib/chat_sdk/x/adapter.rb', line 73

def ack_response(rack_request)
  return nil unless rack_request.get?

  crc_token = rack_request.params["crc_token"]
  return nil unless crc_token

  response_token = "sha256=#{Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", @consumer_secret, crc_token))}"

  [200, {"content-type" => "application/json"}, [JSON.generate({"response_token" => response_token})]]
end

#add_reaction(channel_id:, message_id:, emoji:) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



160
161
162
# File 'lib/chat_sdk/x/adapter.rb', line 160

def add_reaction(channel_id:, message_id:, emoji:) # rubocop:disable Lint/UnusedMethodArgument
  @client.like_tweet(user_id: @user_id, tweet_id: message_id)
end

#delete_message(channel_id:, message_id:) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



127
128
129
# File 'lib/chat_sdk/x/adapter.rb', line 127

def delete_message(channel_id:, message_id:) # rubocop:disable Lint/UnusedMethodArgument
  @client.delete_tweet(tweet_id: message_id)
end

#fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/chat_sdk/x/adapter.rb', line 131

def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50) # rubocop:disable Lint/UnusedMethodArgument
  if thread_id&.start_with?("x:dm:")
    participant_id = thread_id.delete_prefix("x:dm:")
    result = @client.get_dm_events(participant_id: participant_id, cursor: cursor, limit: limit)
    data = result["data"] || []
    messages = data.map do |dm|
      ChatSDK::Message.new(
        id: dm["id"]&.to_s,
        text: dm["text"] || "",
        author: ChatSDK::Author.new(
          id: dm["sender_id"] || "unknown",
          name: dm["sender_id"] || "unknown",
          platform: :x,
          bot: false
        ),
        thread_id: thread_id,
        channel_id: channel_id,
        platform: :x,
        raw: dm
      )
    end
    next_cursor = result.dig("meta", "next_token")
    [messages, next_cursor]
  else
    # X does not provide a robust thread-fetching API for tweets
    [[], nil]
  end
end

#get_user(user_id) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
# File 'lib/chat_sdk/x/adapter.rb', line 168

def get_user(user_id)
  data = @client.get_user(user_id)
  return nil unless data&.dig("data", "id")

  ChatSDK::Author.new(
    id: data.dig("data", "id"),
    name: data.dig("data", "username"),
    platform: :x,
    bot: false,
    raw: data
  )
end

#mention(user_id) ⇒ Object



185
186
187
# File 'lib/chat_sdk/x/adapter.rb', line 185

def mention(user_id)
  "@#{user_id}"
end

#nameObject



49
50
51
# File 'lib/chat_sdk/x/adapter.rb', line 49

def name
  :x
end

#open_dm(user_id) ⇒ Object



181
182
183
# File 'lib/chat_sdk/x/adapter.rb', line 181

def open_dm(user_id)
  user_id
end

#parse_events(rack_request) ⇒ Object



84
85
86
87
88
89
# File 'lib/chat_sdk/x/adapter.rb', line 84

def parse_events(rack_request)
  payload = read_json_body(rack_request)
  EventParser.parse(payload, bot_user_id: @user_id)
rescue JSON::ParserError
  []
end

#post_message(channel_id:, message:, thread_id: nil) ⇒ Object

Outbound



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
# File 'lib/chat_sdk/x/adapter.rb', line 92

def post_message(channel_id:, message:, thread_id: nil)
  msg = ChatSDK::PostableMessage.from(message)
  text = msg.text || msg.card&.fallback_text || ""

  if thread_id&.start_with?("x:dm:")
    result = @client.send_dm(participant_id: channel_id, text: text)
    message_id = result.dig("dm_event", "id") || result["id"]

    ChatSDK::Message.new(
      id: message_id,
      text: text,
      author: ChatSDK::Author.new(id: @user_id || "bot", name: "bot", platform: :x, bot: true),
      thread_id: thread_id,
      channel_id: channel_id,
      platform: :x,
      raw: result
    )
  else
    reply_to = (channel_id if channel_id && thread_id)
    result = @client.create_tweet(text: text, reply_to: reply_to)
    parse_tweet_message(result, channel_id, thread_id: thread_id)
  end
end

#remove_reaction(channel_id:, message_id:, emoji:) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



164
165
166
# File 'lib/chat_sdk/x/adapter.rb', line 164

def remove_reaction(channel_id:, message_id:, emoji:) # rubocop:disable Lint/UnusedMethodArgument
  @client.unlike_tweet(user_id: @user_id, tweet_id: message_id)
end

#render(postable_message) ⇒ Object



189
190
191
# File 'lib/chat_sdk/x/adapter.rb', line 189

def render(postable_message)
  postable_message.text
end

#set_state(state) ⇒ Object

Inject state store after initialization (e.g., from Chat instance). Loads any previously persisted tokens so auth survives restarts.



43
44
45
46
47
# File 'lib/chat_sdk/x/adapter.rb', line 43

def set_state(state)
  @state = state
  @client.state = state
  @client.load_stored_token if @client_id
end

#upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil) ⇒ Object

rubocop:disable Lint/UnusedMethodArgument



116
117
118
119
120
121
122
123
124
125
# File 'lib/chat_sdk/x/adapter.rb', line 116

def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil) # rubocop:disable Lint/UnusedMethodArgument
  content_type = detect_content_type(filename)
  bytes = io.respond_to?(:size) ? io.size : io.read.bytesize.tap { io.rewind }

  media_id = @client.upload_media(io: io, content_type: content_type, total_bytes: bytes)

  text = comment || ""
  result = @client.create_tweet(text: text, media_ids: [media_id])
  parse_tweet_message(result, channel_id)
end

#verify_request!(rack_request) ⇒ Object

Inbound



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/chat_sdk/x/adapter.rb', line 54

def verify_request!(rack_request)
  body = rack_request.body.read
  rack_request.body.rewind

  signature = rack_request.get_header("HTTP_X_TWITTER_WEBHOOKS_SIGNATURE")

  unless signature
    raise ChatSDK::SignatureVerificationError, "Missing X signature header"
  end

  expected = "sha256=#{Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", @consumer_secret, body))}"

  unless Rack::Utils.secure_compare(signature, expected)
    raise ChatSDK::SignatureVerificationError, "Invalid X signature"
  end

  true
end