chat_sdk-gchat 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3bfec5f052031774aa8db868e443e960182310d8af06672d7da4a9ea6e5816f8
4
+ data.tar.gz: 53dcace115831d3816b876f4048b3a693e72a46758c9e133dd24c37d8cad7f9d
5
+ SHA512:
6
+ metadata.gz: 1930e5a33ef72f960c3368190b578c963f79e384aad21c39950657e96d2a9c8efcedba418d4b922527198668a76083af503230c0c2f09f92c03692ee40c31529
7
+ data.tar.gz: '08c9d6e254f0d552c94e70f2210b8304159ff02578989f14a1502e8f6fb1df4930eaceed9f891fdeb500879919022331ad69545ceb9c559f89e1d35ccc28f62d'
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module GChat
5
+ class Adapter < ChatSDK::Adapter::Base
6
+ capabilities :edit_messages, :delete_messages, :ephemeral_messages,
7
+ :threads, :direct_messages, :message_history,
8
+ :reactions, :streaming_edit
9
+
10
+ attr_reader :client
11
+
12
+ def initialize(project_number:, credentials: nil)
13
+ @project_number = project_number.to_s
14
+
15
+ raise ChatSDK::ConfigurationError, "Google Chat project_number required" if @project_number.empty?
16
+
17
+ @credentials = build_credentials(credentials)
18
+ @client = Google::Apps::Chat::V1::ChatService::Client.new do |config|
19
+ config.credentials = @credentials if @credentials
20
+ end
21
+ @verifier = TokenVerifier.new(@project_number)
22
+ @renderer = CardV2Renderer.new
23
+ end
24
+
25
+ def name
26
+ :gchat
27
+ end
28
+
29
+ # Inbound
30
+ def verify_request!(rack_request)
31
+ auth_header = rack_request.env["HTTP_AUTHORIZATION"] || ""
32
+ token = auth_header.sub(/\ABearer\s+/i, "")
33
+ @verifier.verify!(token)
34
+ true
35
+ end
36
+
37
+ def parse_events(rack_request)
38
+ body = rack_request.body.read
39
+ rack_request.body.rewind
40
+
41
+ payload = JSON.parse(body)
42
+ EventParser.parse(payload)
43
+ rescue JSON::ParserError
44
+ []
45
+ end
46
+
47
+ # Outbound
48
+ def post_message(channel_id:, message:, thread_id: nil)
49
+ msg = ChatSDK::PostableMessage.from(message)
50
+ request_body = build_message_body(msg)
51
+
52
+ if thread_id
53
+ request_body[:thread] = {name: "spaces/#{channel_id}/threads/#{thread_id}"}
54
+ end
55
+
56
+ result = @client.create_message(
57
+ parent: "spaces/#{channel_id}",
58
+ message: request_body
59
+ )
60
+
61
+ build_response_message(result, channel_id, msg)
62
+ end
63
+
64
+ def edit_message(channel_id:, message_id:, message:)
65
+ require_capability!(:edit_messages)
66
+ msg = ChatSDK::PostableMessage.from(message)
67
+ request_body = build_message_body(msg)
68
+ request_body[:name] = "spaces/#{channel_id}/messages/#{message_id}"
69
+
70
+ @client.update_message(
71
+ message: request_body,
72
+ update_mask: Google::Protobuf::FieldMask.new(paths: ["text", "cards_v2"])
73
+ )
74
+ end
75
+
76
+ def delete_message(channel_id:, message_id:)
77
+ require_capability!(:delete_messages)
78
+ @client.delete_message(name: "spaces/#{channel_id}/messages/#{message_id}")
79
+ end
80
+
81
+ def post_ephemeral(channel_id:, user_id:, message:, thread_id: nil)
82
+ require_capability!(:ephemeral_messages)
83
+ msg = ChatSDK::PostableMessage.from(message)
84
+ request_body = build_message_body(msg)
85
+ request_body[:private_message_viewer] = {name: "users/#{user_id}"}
86
+
87
+ if thread_id
88
+ request_body[:thread] = {name: "spaces/#{channel_id}/threads/#{thread_id}"}
89
+ end
90
+
91
+ result = @client.create_message(
92
+ parent: "spaces/#{channel_id}",
93
+ message: request_body
94
+ )
95
+
96
+ build_response_message(result, channel_id, msg)
97
+ end
98
+
99
+ def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
100
+ super
101
+ end
102
+
103
+ def add_reaction(channel_id:, message_id:, emoji:)
104
+ require_capability!(:reactions)
105
+ @client.create_reaction(
106
+ parent: "spaces/#{channel_id}/messages/#{message_id}",
107
+ reaction: {
108
+ emoji: {unicode: emoji}
109
+ }
110
+ )
111
+ end
112
+
113
+ def remove_reaction(channel_id:, message_id:, emoji:)
114
+ require_capability!(:reactions)
115
+ # Google Chat requires the reaction resource name to delete
116
+ # We construct it from the emoji unicode
117
+ @client.delete_reaction(
118
+ name: "spaces/#{channel_id}/messages/#{message_id}/reactions/#{emoji}"
119
+ )
120
+ end
121
+
122
+ def open_dm(user_id)
123
+ require_capability!(:direct_messages)
124
+ result = @client.setup_space(
125
+ space: {space_type: "DIRECT_MESSAGE"},
126
+ memberships: [{member: {name: "users/#{user_id}", type: "HUMAN"}}]
127
+ )
128
+ extract_id(result.name)
129
+ end
130
+
131
+ def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
132
+ require_capability!(:message_history)
133
+ result = @client.list_messages(
134
+ parent: "spaces/#{channel_id}",
135
+ page_size: limit,
136
+ page_token: cursor
137
+ )
138
+
139
+ messages = result.messages.map { |m| parse_gchat_message(m, channel_id) }
140
+ [messages, result.next_page_token.to_s.empty? ? nil : result.next_page_token]
141
+ end
142
+
143
+ def open_modal(trigger_id:, modal:)
144
+ super
145
+ end
146
+
147
+ def start_typing(channel_id:, thread_id: nil)
148
+ super
149
+ end
150
+
151
+ def mention(user_id)
152
+ "<users/#{user_id}>"
153
+ end
154
+
155
+ def render(postable_message)
156
+ if postable_message.card?
157
+ @renderer.render(postable_message.card)
158
+ else
159
+ postable_message.text
160
+ end
161
+ end
162
+
163
+ private
164
+
165
+ def build_credentials(credentials)
166
+ case credentials
167
+ when nil
168
+ # Use Application Default Credentials
169
+ Google::Auth.get_application_default(
170
+ ["https://www.googleapis.com/auth/chat.bot"]
171
+ )
172
+ when String
173
+ # Path to service account JSON
174
+ Google::Auth::ServiceAccountCredentials.make_creds(
175
+ json_key_io: File.open(credentials),
176
+ scope: "https://www.googleapis.com/auth/chat.bot"
177
+ )
178
+ when Hash
179
+ # Service account JSON as a hash
180
+ Google::Auth::ServiceAccountCredentials.make_creds(
181
+ json_key_io: StringIO.new(JSON.generate(credentials)),
182
+ scope: "https://www.googleapis.com/auth/chat.bot"
183
+ )
184
+ else
185
+ credentials
186
+ end
187
+ end
188
+
189
+ def build_message_body(msg)
190
+ body = {}
191
+
192
+ if msg.card?
193
+ rendered = @renderer.render(msg.card)
194
+ body[:cards_v2] = rendered[:cards_v2] if rendered[:cards_v2]
195
+ body[:text] = msg.text || msg.card.fallback_text
196
+ else
197
+ body[:text] = msg.text
198
+ end
199
+
200
+ body
201
+ end
202
+
203
+ def build_response_message(result, channel_id, msg)
204
+ ChatSDK::Message.new(
205
+ id: extract_id(result.name),
206
+ text: msg.text || "",
207
+ author: ChatSDK::Author.new(id: "bot", name: "bot", platform: :gchat, bot: true),
208
+ thread_id: extract_id(result.thread&.name),
209
+ channel_id: channel_id,
210
+ platform: :gchat,
211
+ raw: result
212
+ )
213
+ end
214
+
215
+ def parse_gchat_message(msg, channel_id)
216
+ sender = msg.sender
217
+ ChatSDK::Message.new(
218
+ id: extract_id(msg.name),
219
+ text: msg.text || "",
220
+ author: ChatSDK::Author.new(
221
+ id: extract_id(sender&.name || "unknown"),
222
+ name: sender&.display_name || "unknown",
223
+ platform: :gchat,
224
+ bot: sender&.type == :BOT
225
+ ),
226
+ thread_id: extract_id(msg.thread&.name),
227
+ channel_id: channel_id,
228
+ platform: :gchat,
229
+ raw: msg
230
+ )
231
+ end
232
+
233
+ def extract_id(resource_name)
234
+ return resource_name unless resource_name.is_a?(String)
235
+ resource_name.split("/").last || resource_name
236
+ end
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module GChat
5
+ class CardV2Renderer
6
+ def render(node)
7
+ case node.type
8
+ when :card then render_card(node)
9
+ else {cards_v2: [{card: {sections: [{widgets: [render_widget(node)]}]}}]}
10
+ end
11
+ end
12
+
13
+ private
14
+
15
+ def render_card(node)
16
+ header = build_header(node)
17
+ sections = build_sections(node.children)
18
+
19
+ card = {}
20
+ card[:header] = header if header
21
+ card[:sections] = sections unless sections.empty?
22
+
23
+ {cards_v2: [{card: card}]}
24
+ end
25
+
26
+ def build_header(node)
27
+ return nil unless node.attributes[:title]
28
+
29
+ header = {title: node.attributes[:title]}
30
+ header[:subtitle] = node.attributes[:subtitle] if node.attributes[:subtitle]
31
+ header
32
+ end
33
+
34
+ def build_sections(children)
35
+ sections = []
36
+ current_widgets = []
37
+
38
+ children.each do |child|
39
+ case child.type
40
+ when :divider
41
+ sections << {widgets: current_widgets} unless current_widgets.empty?
42
+ current_widgets = []
43
+ sections << {widgets: [{divider: {}}]}
44
+ when :section
45
+ sections << {widgets: current_widgets} unless current_widgets.empty?
46
+ current_widgets = []
47
+ section = {}
48
+ section[:header] = child.attributes[:title] if child.attributes[:title]
49
+ section[:widgets] = child.children.flat_map { |c| render_widgets(c) }.compact
50
+ sections << section
51
+ else
52
+ render_widgets(child).each { |w| current_widgets << w }
53
+ end
54
+ end
55
+
56
+ sections << {widgets: current_widgets} unless current_widgets.empty?
57
+ sections
58
+ end
59
+
60
+ def render_widget(node)
61
+ case node.type
62
+ when :text then render_text(node)
63
+ when :fields then render_fields(node)
64
+ when :image then render_image(node)
65
+ when :actions then render_actions(node)
66
+ when :divider then {divider: {}}
67
+ when :select then render_select(node)
68
+ end
69
+ end
70
+
71
+ def render_widgets(node)
72
+ case node.type
73
+ when :actions then render_actions_as_widgets(node)
74
+ else
75
+ widget = render_widget(node)
76
+ widget ? [widget] : []
77
+ end
78
+ end
79
+
80
+ def render_text(node)
81
+ {textParagraph: {text: node.attributes[:content]}}
82
+ end
83
+
84
+ def render_fields(node)
85
+ {
86
+ decoratedText: {
87
+ topLabel: node.children.map { |f| f.attributes[:label] }.join(" | "),
88
+ text: node.children.map { |f| f.attributes[:value] }.join(" | ")
89
+ }
90
+ }
91
+ end
92
+
93
+ def render_image(node)
94
+ img = {imageUrl: node.attributes[:url]}
95
+ img[:altText] = node.attributes[:alt] if node.attributes[:alt]
96
+ {image: img}
97
+ end
98
+
99
+ def render_actions(node)
100
+ widgets = render_actions_as_widgets(node)
101
+ # For single-widget render path, return the first widget (buttonList or select)
102
+ widgets.first
103
+ end
104
+
105
+ def render_actions_as_widgets(node)
106
+ widgets = []
107
+ buttons = []
108
+ node.children.each do |child|
109
+ if child.type == :select
110
+ widgets << render_button_list(buttons) unless buttons.empty?
111
+ buttons = []
112
+ widgets << render_select(child)
113
+ else
114
+ btn = render_action_element(child)
115
+ buttons << btn if btn
116
+ end
117
+ end
118
+ widgets << render_button_list(buttons) unless buttons.empty?
119
+ widgets
120
+ end
121
+
122
+ def render_button_list(buttons)
123
+ {buttonList: {buttons: buttons}}
124
+ end
125
+
126
+ def render_action_element(node)
127
+ case node.type
128
+ when :button then render_button(node)
129
+ when :link_button then render_link_button(node)
130
+ when :select then nil # selects are rendered as their own widget
131
+ end
132
+ end
133
+
134
+ def render_button(node)
135
+ btn = {
136
+ text: node.attributes[:text],
137
+ onClick: {
138
+ action: {
139
+ actionMethodName: node.attributes[:id]
140
+ }
141
+ }
142
+ }
143
+
144
+ if node.attributes[:value]
145
+ btn[:onClick][:action][:parameters] = [
146
+ {key: "value", value: node.attributes[:value]}
147
+ ]
148
+ end
149
+
150
+ if node.attributes[:style] == :primary
151
+ btn[:color] = {red: 0.0, green: 0.53, blue: 0.87, alpha: 1.0}
152
+ elsif node.attributes[:style] == :danger
153
+ btn[:color] = {red: 0.87, green: 0.17, blue: 0.17, alpha: 1.0}
154
+ end
155
+
156
+ btn
157
+ end
158
+
159
+ def render_link_button(node)
160
+ {
161
+ text: node.attributes[:text],
162
+ onClick: {
163
+ openLink: {url: node.attributes[:url]}
164
+ }
165
+ }
166
+ end
167
+
168
+ def render_select(node)
169
+ {
170
+ selectionInput: {
171
+ name: node.attributes[:id],
172
+ label: node.attributes[:placeholder] || "Select",
173
+ type: "DROPDOWN",
174
+ items: node.children.map { |opt| render_select_option(opt) }
175
+ }
176
+ }
177
+ end
178
+
179
+ def render_select_option(node)
180
+ {
181
+ text: node.attributes[:text],
182
+ value: node.attributes[:value],
183
+ selected: false
184
+ }
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module GChat
5
+ class EventParser
6
+ class << self
7
+ def parse(payload)
8
+ type = payload["type"]
9
+
10
+ case type
11
+ when "MESSAGE"
12
+ parse_message(payload)
13
+ when "CARD_CLICKED"
14
+ parse_card_clicked(payload)
15
+ when "ADDED_TO_SPACE", "REMOVED_FROM_SPACE"
16
+ []
17
+ else
18
+ []
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def parse_message(payload)
25
+ msg_data = payload["message"] || {}
26
+ sender = msg_data["sender"] || {}
27
+ space = msg_data["space"] || {}
28
+ thread_data = msg_data["thread"] || {}
29
+
30
+ author = ChatSDK::Author.new(
31
+ id: extract_id(sender["name"]),
32
+ name: sender["displayName"] || sender["name"] || "unknown",
33
+ platform: :gchat,
34
+ bot: sender["type"] == "BOT"
35
+ )
36
+
37
+ message = ChatSDK::Message.new(
38
+ id: extract_id(msg_data["name"]),
39
+ text: msg_data["text"] || msg_data["argumentText"] || "",
40
+ author: author,
41
+ thread_id: extract_id(thread_data["name"]),
42
+ channel_id: extract_id(space["name"]),
43
+ platform: :gchat,
44
+ raw: msg_data
45
+ )
46
+
47
+ if bot_mentioned?(msg_data)
48
+ [ChatSDK::Events::Mention.new(
49
+ message: message,
50
+ thread_id: message.thread_id,
51
+ channel_id: message.channel_id,
52
+ platform: :gchat,
53
+ adapter_name: :gchat,
54
+ raw: payload
55
+ )]
56
+ else
57
+ [ChatSDK::Events::SubscribedMessage.new(
58
+ message: message,
59
+ thread_id: message.thread_id,
60
+ channel_id: message.channel_id,
61
+ platform: :gchat,
62
+ adapter_name: :gchat,
63
+ raw: payload
64
+ )]
65
+ end
66
+ end
67
+
68
+ def parse_card_clicked(payload)
69
+ action = payload.dig("action", "actionMethodName") || payload.dig("common", "invokedFunction")
70
+ params = payload.dig("action", "parameters") || []
71
+ value = params.first&.dig("value")
72
+
73
+ user_data = payload["user"] || {}
74
+ space = payload.dig("message", "space") || payload["space"] || {}
75
+ thread_data = payload.dig("message", "thread") || {}
76
+
77
+ user = ChatSDK::Author.new(
78
+ id: extract_id(user_data["name"]),
79
+ name: user_data["displayName"] || user_data["name"] || "unknown",
80
+ platform: :gchat
81
+ )
82
+
83
+ [ChatSDK::Events::Action.new(
84
+ action_id: action || "unknown",
85
+ value: value,
86
+ user: user,
87
+ thread_id: extract_id(thread_data["name"]),
88
+ channel_id: extract_id(space["name"]),
89
+ platform: :gchat,
90
+ adapter_name: :gchat,
91
+ raw: payload
92
+ )]
93
+ end
94
+
95
+ def bot_mentioned?(msg_data)
96
+ annotations = msg_data["annotations"] || []
97
+ annotations.any? { |a| a["type"] == "USER_MENTION" && a.dig("userMention", "type") == "MENTION" }
98
+ end
99
+
100
+ def extract_id(resource_name)
101
+ return resource_name unless resource_name.is_a?(String)
102
+ resource_name.split("/").last || resource_name
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ChatSDK
4
+ module GChat
5
+ class TokenVerifier
6
+ EXPECTED_ISSUER = "chat@system.gserviceaccount.com"
7
+
8
+ def initialize(project_number)
9
+ @project_number = project_number.to_s
10
+ end
11
+
12
+ def verify!(token)
13
+ raise ChatSDK::SignatureVerificationError, "Missing bearer token" if token.nil? || token.empty?
14
+
15
+ payload = Google::Auth::IDTokens.verify_oidc(
16
+ token,
17
+ aud: @project_number
18
+ )
19
+
20
+ issuer = payload["iss"] || payload["email"]
21
+ unless issuer == EXPECTED_ISSUER
22
+ raise ChatSDK::SignatureVerificationError,
23
+ "Unexpected issuer: #{issuer} (expected #{EXPECTED_ISSUER})"
24
+ end
25
+
26
+ payload
27
+ rescue Google::Auth::IDTokens::VerificationError => e
28
+ raise ChatSDK::SignatureVerificationError, "Google ID token verification failed: #{e.message}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "chat_sdk"
4
+ require "google/apps/chat/v1"
5
+ require "googleauth"
6
+ require "zeitwerk"
7
+
8
+ module ChatSDK
9
+ module GChat
10
+ class << self
11
+ def loader
12
+ @loader ||= begin
13
+ loader = Zeitwerk::Loader.new
14
+ loader.tag = "chat_sdk-gchat"
15
+ loader.inflector.inflect("chat_sdk" => "ChatSDK", "gchat" => "GChat")
16
+ loader.push_dir("#{__dir__}/gchat", namespace: ChatSDK::GChat)
17
+ loader.setup
18
+ loader
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ ChatSDK::GChat.loader
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: chat_sdk-gchat
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rootly
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: google-apps-chat-v1
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: googleauth
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ description: Google Chat bot adapter for the ChatSDK framework
55
+ email:
56
+ - eng@rootly.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/chat_sdk/gchat.rb
62
+ - lib/chat_sdk/gchat/adapter.rb
63
+ - lib/chat_sdk/gchat/card_v2_renderer.rb
64
+ - lib/chat_sdk/gchat/event_parser.rb
65
+ - lib/chat_sdk/gchat/token_verifier.rb
66
+ homepage: https://github.com/rootlyhq/rootly-chat-sdk
67
+ licenses:
68
+ - MIT
69
+ metadata:
70
+ rubygems_mfa_required: 'true'
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: 3.2.0
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.16
86
+ specification_version: 4
87
+ summary: Google Chat adapter for ChatSDK
88
+ test_files: []