chat_sdk-x 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5d20a70961f55e903fc2adc6155f5e3bbff60910df577afc4948421f611d08f0
4
+ data.tar.gz: 00c9a4d7394037ec77d1cdbd3fc6f9462009a61c01c8f15acc349fb872b63682
5
+ SHA512:
6
+ metadata.gz: 3edc7cf5cc52d757d1096f248e35763e192cda98f904ec3dfde7c7fad5c0b17290efe57d853c5cb09f686b8b54f302011376189aefa0ed3177f08b275730a2d7
7
+ data.tar.gz: e3954e97efc0e99e52787e9934f26d4f0e36df7827352b2a960da04cbfe37346b6a6647c4e8bd5d11cd43692149f3fafe8ea2881b92cd4665cec6c676cefaabc
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "rack/utils"
6
+
7
+ module ChatSDK
8
+ module X
9
+ class Adapter < ChatSDK::Adapter::Base
10
+ capabilities :direct_messages, :reactions
11
+
12
+ attr_reader :client
13
+
14
+ def initialize(access_token: nil, consumer_secret: nil, user_id: nil)
15
+ @access_token = access_token || ENV["X_ACCESS_TOKEN"]
16
+ @consumer_secret = consumer_secret || ENV["X_CONSUMER_SECRET"]
17
+ @user_id = user_id || ENV["X_USER_ID"]
18
+
19
+ raise ChatSDK::ConfigurationError, "X access_token required" unless @access_token
20
+ raise ChatSDK::ConfigurationError, "X consumer_secret required" unless @consumer_secret
21
+
22
+ @client = ApiClient.new(@access_token)
23
+ end
24
+
25
+ def name
26
+ :x
27
+ end
28
+
29
+ # Inbound
30
+ def verify_request!(rack_request)
31
+ body = rack_request.body.read
32
+ rack_request.body.rewind
33
+
34
+ signature = rack_request.get_header("HTTP_X_TWITTER_WEBHOOKS_SIGNATURE")
35
+
36
+ unless signature
37
+ raise ChatSDK::SignatureVerificationError, "Missing X signature header"
38
+ end
39
+
40
+ expected = "sha256=#{Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", @consumer_secret, body))}"
41
+
42
+ unless Rack::Utils.secure_compare(signature, expected)
43
+ raise ChatSDK::SignatureVerificationError, "Invalid X signature"
44
+ end
45
+
46
+ true
47
+ end
48
+
49
+ def ack_response(rack_request)
50
+ return nil unless rack_request.get?
51
+
52
+ crc_token = rack_request.params["crc_token"]
53
+ return nil unless crc_token
54
+
55
+ response_token = "sha256=#{Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", @consumer_secret, crc_token))}"
56
+
57
+ [200, {"content-type" => "application/json"}, [JSON.generate({"response_token" => response_token})]]
58
+ end
59
+
60
+ def parse_events(rack_request)
61
+ payload = read_json_body(rack_request)
62
+ EventParser.parse(payload, bot_user_id: @user_id)
63
+ rescue JSON::ParserError
64
+ []
65
+ end
66
+
67
+ # Outbound
68
+ def post_message(channel_id:, message:, thread_id: nil)
69
+ msg = ChatSDK::PostableMessage.from(message)
70
+ text = msg.text || msg.card&.fallback_text || ""
71
+
72
+ if thread_id&.start_with?("x:dm:")
73
+ result = @client.send_dm(participant_id: channel_id, text: text)
74
+ message_id = result.dig("dm_event", "id") || result["id"]
75
+
76
+ ChatSDK::Message.new(
77
+ id: message_id,
78
+ text: text,
79
+ author: ChatSDK::Author.new(id: @user_id || "bot", name: "bot", platform: :x, bot: true),
80
+ thread_id: thread_id,
81
+ channel_id: channel_id,
82
+ platform: :x,
83
+ raw: result
84
+ )
85
+ else
86
+ payload = {text: text}
87
+ payload[:reply] = {in_reply_to_tweet_id: channel_id} if channel_id && thread_id
88
+ result = @client.create_tweet(**payload)
89
+ tweet_id = result.dig("data", "id") || result["id"]
90
+
91
+ ChatSDK::Message.new(
92
+ id: tweet_id,
93
+ text: text,
94
+ author: ChatSDK::Author.new(id: @user_id || "bot", name: "bot", platform: :x, bot: true),
95
+ thread_id: thread_id || "x:post:#{tweet_id}",
96
+ channel_id: channel_id,
97
+ platform: :x,
98
+ raw: result
99
+ )
100
+ end
101
+ end
102
+
103
+ def add_reaction(channel_id:, message_id:, emoji:) # rubocop:disable Lint/UnusedMethodArgument
104
+ @client.like_tweet(user_id: @user_id, tweet_id: message_id)
105
+ end
106
+
107
+ def remove_reaction(channel_id:, message_id:, emoji:) # rubocop:disable Lint/UnusedMethodArgument
108
+ @client.unlike_tweet(user_id: @user_id, tweet_id: message_id)
109
+ end
110
+
111
+ def open_dm(user_id)
112
+ user_id
113
+ end
114
+
115
+ def mention(user_id)
116
+ "@#{user_id}"
117
+ end
118
+
119
+ def render(postable_message)
120
+ postable_message.text
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module X
5
+ class ApiClient < ChatSDK::ApiClient::Base
6
+ BASE_URL = "https://api.x.com"
7
+
8
+ def initialize(access_token)
9
+ @access_token = access_token
10
+ end
11
+
12
+ def create_tweet(text:, reply: nil)
13
+ body = {"text" => text}
14
+ body["reply"] = reply if reply
15
+ request(:post, "/2/tweets", body)
16
+ end
17
+
18
+ def send_dm(participant_id:, text:)
19
+ request(:post, "/2/dm_conversations/with/#{participant_id}/messages", {"text" => text})
20
+ end
21
+
22
+ def like_tweet(user_id:, tweet_id:)
23
+ request(:post, "/2/users/#{user_id}/likes", {"tweet_id" => tweet_id})
24
+ end
25
+
26
+ def unlike_tweet(user_id:, tweet_id:)
27
+ request(:delete, "/2/users/#{user_id}/likes/#{tweet_id}")
28
+ end
29
+
30
+ private
31
+
32
+ def base_url
33
+ BASE_URL
34
+ end
35
+
36
+ def adapter_name
37
+ :x
38
+ end
39
+
40
+ def configure_auth(faraday)
41
+ faraday.headers["Authorization"] = "Bearer #{@access_token}"
42
+ end
43
+
44
+ def extract_error_message(response)
45
+ body = response.body
46
+ return response.status.to_s unless body.is_a?(Hash)
47
+
48
+ body.dig("errors", 0, "message") || body["detail"] || response.status.to_s
49
+ end
50
+
51
+ def extract_retry_after(response)
52
+ reset = response.headers["x-rate-limit-reset"]
53
+ return nil unless reset
54
+
55
+ seconds = reset.to_i - Time.now.to_i
56
+ [seconds, 1].max
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module X
5
+ class EventParser
6
+ class << self
7
+ def parse(payload, bot_user_id: nil)
8
+ return [] unless payload.is_a?(Hash)
9
+
10
+ events = []
11
+ events.concat(parse_mentions(payload, bot_user_id))
12
+ events.concat(parse_direct_messages(payload, bot_user_id))
13
+ events
14
+ end
15
+
16
+ private
17
+
18
+ def parse_mentions(payload, bot_user_id)
19
+ tweet_events = payload["tweet_create_events"] || payload.dig("post", "mention", "create") || []
20
+ return [] unless tweet_events.is_a?(Array)
21
+
22
+ tweet_events.filter_map do |data|
23
+ data = data["data"] if data.key?("data")
24
+ author_id = data["author_id"] || data.dig("user", "id_str") || data.dig("user", "id")&.to_s
25
+ next if bot_user_id && author_id == bot_user_id
26
+
27
+ message_id = data["id"] || data["id_str"]
28
+ text = data["text"] || ""
29
+ conversation_id = data["conversation_id"] || message_id
30
+
31
+ msg = ChatSDK::Message.new(
32
+ id: message_id&.to_s,
33
+ text: text,
34
+ author: ChatSDK::Author.new(id: author_id || "unknown", name: author_id || "unknown", platform: :x, bot: false),
35
+ thread_id: "x:post:#{conversation_id}",
36
+ channel_id: author_id,
37
+ platform: :x,
38
+ raw: data
39
+ )
40
+
41
+ ChatSDK::Events::Mention.new(
42
+ message: msg,
43
+ thread_id: "x:post:#{conversation_id}",
44
+ channel_id: author_id,
45
+ platform: :x,
46
+ adapter_name: :x,
47
+ raw: data
48
+ )
49
+ end
50
+ end
51
+
52
+ def parse_direct_messages(payload, bot_user_id)
53
+ dm_events = payload["direct_message_events"] || payload.dig("dm", "received") || []
54
+ return [] unless dm_events.is_a?(Array)
55
+
56
+ dm_events.filter_map do |data|
57
+ data = data["data"] if data.is_a?(Hash) && data.key?("data")
58
+ sender_id = data["sender_id"] || data.dig("message_create", "sender_id")
59
+ next if bot_user_id && sender_id == bot_user_id
60
+
61
+ text = data["text"] || data.dig("message_create", "message_data", "text") || ""
62
+ message_id = data["id"] || data["id_str"]
63
+
64
+ msg = ChatSDK::Message.new(
65
+ id: message_id&.to_s,
66
+ text: text,
67
+ author: ChatSDK::Author.new(id: sender_id || "unknown", name: sender_id || "unknown", platform: :x, bot: false),
68
+ thread_id: "x:dm:#{sender_id}",
69
+ channel_id: sender_id,
70
+ platform: :x,
71
+ raw: data
72
+ )
73
+
74
+ ChatSDK::Events::DirectMessage.new(
75
+ message: msg,
76
+ thread_id: "x:dm:#{sender_id}",
77
+ channel_id: sender_id,
78
+ platform: :x,
79
+ adapter_name: :x,
80
+ raw: data
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
data/lib/chat_sdk/x.rb ADDED
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "faraday"
5
+ require "faraday/net_http"
6
+ require "zeitwerk"
7
+
8
+ module ChatSDK
9
+ module X
10
+ class << self
11
+ def loader
12
+ @loader ||= begin
13
+ loader = Zeitwerk::Loader.new
14
+ loader.tag = "chat_sdk-x"
15
+ loader.inflector.inflect("chat_sdk" => "ChatSDK", "x" => "X")
16
+ loader.push_dir("#{__dir__}/x", namespace: ChatSDK::X)
17
+ loader.setup
18
+ loader
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ChatSDK::X.loader
metadata ADDED
@@ -0,0 +1,73 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-x
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.1
5
+ platform: ruby
6
+ authors:
7
+ - Quentin Rousseau
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: chat_sdk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: X (Twitter) bot adapter for the ChatSDK framework
41
+ email:
42
+ - quentin@rootly.com
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - lib/chat_sdk/x.rb
48
+ - lib/chat_sdk/x/adapter.rb
49
+ - lib/chat_sdk/x/api_client.rb
50
+ - lib/chat_sdk/x/event_parser.rb
51
+ homepage: https://github.com/rootlyhq/chat-sdk
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ rubygems_mfa_required: 'true'
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.2.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.16
71
+ specification_version: 4
72
+ summary: X adapter for ChatSDK
73
+ test_files: []