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 +7 -0
- data/lib/chat_sdk/x/adapter.rb +124 -0
- data/lib/chat_sdk/x/api_client.rb +60 -0
- data/lib/chat_sdk/x/event_parser.rb +87 -0
- data/lib/chat_sdk/x.rb +25 -0
- metadata +73 -0
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: []
|