teams_rb 2.0.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/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +500 -0
- data/docs/README.md +40 -0
- data/docs/essentials/README.md +13 -0
- data/docs/essentials/api-client.md +62 -0
- data/docs/essentials/app-authentication.md +27 -0
- data/docs/essentials/app-basics.md +50 -0
- data/docs/essentials/graph.md +41 -0
- data/docs/essentials/on-activity.md +72 -0
- data/docs/essentials/on-event.md +29 -0
- data/docs/essentials/proactive-messaging.md +44 -0
- data/docs/essentials/sending-messages.md +76 -0
- data/docs/essentials/sovereign-cloud.md +26 -0
- data/docs/getting-started/README.md +7 -0
- data/docs/getting-started/code-basics.md +61 -0
- data/docs/getting-started/quickstart.md +54 -0
- data/docs/getting-started/running-in-teams.md +44 -0
- data/docs/in-depth-guides/README.md +14 -0
- data/docs/in-depth-guides/adaptive-cards.md +62 -0
- data/docs/in-depth-guides/dialogs.md +77 -0
- data/docs/in-depth-guides/feedback.md +29 -0
- data/docs/in-depth-guides/meeting-events.md +27 -0
- data/docs/in-depth-guides/message-extensions.md +77 -0
- data/docs/in-depth-guides/message-reactions.md +29 -0
- data/docs/in-depth-guides/observability.md +28 -0
- data/docs/in-depth-guides/streaming.md +52 -0
- data/docs/in-depth-guides/tabs.md +59 -0
- data/docs/in-depth-guides/user-authentication.md +60 -0
- data/lib/teams/activity.rb +160 -0
- data/lib/teams/activity_context.rb +278 -0
- data/lib/teams/api/account.rb +90 -0
- data/lib/teams/api/activity_value.rb +49 -0
- data/lib/teams/api/bot_sign_in_client.rb +54 -0
- data/lib/teams/api/channel_data.rb +86 -0
- data/lib/teams/api/channel_info.rb +19 -0
- data/lib/teams/api/citation_appearance.rb +83 -0
- data/lib/teams/api/client.rb +28 -0
- data/lib/teams/api/conversation_account.rb +40 -0
- data/lib/teams/api/conversation_client.rb +156 -0
- data/lib/teams/api/conversation_reference.rb +83 -0
- data/lib/teams/api/conversation_resource.rb +19 -0
- data/lib/teams/api/meeting_client.rb +57 -0
- data/lib/teams/api/meeting_info.rb +26 -0
- data/lib/teams/api/meeting_notification_response.rb +29 -0
- data/lib/teams/api/meeting_participant.rb +33 -0
- data/lib/teams/api/message_activity.rb +201 -0
- data/lib/teams/api/message_extension.rb +110 -0
- data/lib/teams/api/model.rb +49 -0
- data/lib/teams/api/notification_info.rb +28 -0
- data/lib/teams/api/paged_members_result.rb +16 -0
- data/lib/teams/api/quoted_reply_entity.rb +65 -0
- data/lib/teams/api/reaction_client.rb +34 -0
- data/lib/teams/api/sent_activity.rb +48 -0
- data/lib/teams/api/task_module.rb +83 -0
- data/lib/teams/api/team_client.rb +45 -0
- data/lib/teams/api/team_details.rb +36 -0
- data/lib/teams/api/team_info.rb +44 -0
- data/lib/teams/api/tenant_info.rb +11 -0
- data/lib/teams/api/token.rb +81 -0
- data/lib/teams/api/typing_activity.rb +19 -0
- data/lib/teams/api/user_client.rb +83 -0
- data/lib/teams/app.rb +602 -0
- data/lib/teams/auth/client_secret_credentials.rb +7 -0
- data/lib/teams/auth/jwt_validator.rb +148 -0
- data/lib/teams/auth/token.rb +39 -0
- data/lib/teams/auth/token_manager.rb +68 -0
- data/lib/teams/cards/generated.rb +1764 -0
- data/lib/teams/cards/generated_base.rb +105 -0
- data/lib/teams/cards.rb +81 -0
- data/lib/teams/cloud_environment.rb +41 -0
- data/lib/teams/common/hashes.rb +20 -0
- data/lib/teams/common/http_client.rb +111 -0
- data/lib/teams/common/retry.rb +31 -0
- data/lib/teams/errors.rb +50 -0
- data/lib/teams/function_context.rb +82 -0
- data/lib/teams/graph/client.rb +73 -0
- data/lib/teams/http_stream.rb +460 -0
- data/lib/teams/rack_app.rb +62 -0
- data/lib/teams/response.rb +9 -0
- data/lib/teams/router.rb +171 -0
- data/lib/teams/storage/memory_store.rb +27 -0
- data/lib/teams/version.rb +5 -0
- data/lib/teams.rb +70 -0
- metadata +217 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
# HTTP-based streaming for Microsoft Teams activities.
|
|
5
|
+
#
|
|
6
|
+
# Emits are queued and flushed by a background thread, like the TypeScript
|
|
7
|
+
# and Python streamers: each flush drains the whole queue, coalescing the
|
|
8
|
+
# accumulated message text into one typing chunk, and consecutive flushes
|
|
9
|
+
# are spaced apart to stay under Teams rate limits. close waits for the
|
|
10
|
+
# queue to drain before sending the final message.
|
|
11
|
+
class HttpStream
|
|
12
|
+
STREAM_CHANNEL_DATA_KEYS = %w[streamId streamType streamSequence].freeze
|
|
13
|
+
NON_RETRYABLE_ERRORS = [TerminalStreamError, StreamCancelledError].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :app, :conversation_reference
|
|
16
|
+
|
|
17
|
+
def initialize(app:, conversation_reference:)
|
|
18
|
+
@app = app
|
|
19
|
+
@conversation_reference = conversation_reference
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@flusher = nil
|
|
22
|
+
@flushing = false
|
|
23
|
+
@flush_interval = 0.5
|
|
24
|
+
@poll_interval = 0.1
|
|
25
|
+
@total_wait_timeout = 30
|
|
26
|
+
# Chunk sends use the Python streamer's tuned retry options; other
|
|
27
|
+
# stream sends use the shared defaults.
|
|
28
|
+
@chunk_retry = { max_attempts: 8, delay: 0.5, max_delay: 4.0, jitter: :none }
|
|
29
|
+
@send_retry = { max_attempts: 5, delay: 0.5, max_delay: 30.0, jitter: :full }
|
|
30
|
+
reset_state
|
|
31
|
+
@result = nil
|
|
32
|
+
@canceled = false
|
|
33
|
+
@timed_out = false
|
|
34
|
+
@chunk_handlers = []
|
|
35
|
+
@close_handlers = []
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Registers a handler called with the SentActivity of every stream chunk.
|
|
39
|
+
# Handlers persist across stream reuse and run on the flusher thread.
|
|
40
|
+
def on_chunk(&handler)
|
|
41
|
+
@chunk_handlers << handler
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Registers a handler called with the final SentActivity when the stream
|
|
46
|
+
# closes. Handlers persist across stream reuse.
|
|
47
|
+
def on_close(&handler)
|
|
48
|
+
@close_handlers << handler
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def canceled
|
|
53
|
+
@canceled
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def timed_out
|
|
57
|
+
@timed_out
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def closed
|
|
61
|
+
!@result.nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def count
|
|
65
|
+
@mutex.synchronize { @queue.length }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sequence
|
|
69
|
+
@mutex.synchronize { @sequence }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def emit(activity_or_text)
|
|
73
|
+
raise StreamCancelledError, "Stream has been cancelled." if canceled
|
|
74
|
+
|
|
75
|
+
activity = normalize_activity(activity_or_text)
|
|
76
|
+
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
# Emitting after close reopens the stream: start a new streamed
|
|
79
|
+
# message on the same instance. The canceled flag stays sticky.
|
|
80
|
+
reset_for_next_stream if closed
|
|
81
|
+
|
|
82
|
+
@queue << activity
|
|
83
|
+
@flusher ||= Thread.new { run_flusher }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def update(text)
|
|
90
|
+
emit(
|
|
91
|
+
"type" => "typing",
|
|
92
|
+
"text" => text,
|
|
93
|
+
"channelData" => { "streamType" => "informative" }
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clear_text
|
|
98
|
+
@mutex.synchronize do
|
|
99
|
+
@text = +""
|
|
100
|
+
@queue.reject! { |activity| activity["type"] == "message" }
|
|
101
|
+
@final_activity = nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def close
|
|
106
|
+
return @result if closed
|
|
107
|
+
return nil if canceled
|
|
108
|
+
return nil if no_content_pending?
|
|
109
|
+
|
|
110
|
+
return nil unless wait_for_flush
|
|
111
|
+
|
|
112
|
+
return nil if canceled
|
|
113
|
+
|
|
114
|
+
unless @id
|
|
115
|
+
app.logger&.warn("no stream id set, cannot close stream")
|
|
116
|
+
return nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return nil unless final_content?
|
|
120
|
+
|
|
121
|
+
# Merging the sent activity with the response keeps the id available
|
|
122
|
+
# even though live Teams answers follow-up stream posts with 202 and an
|
|
123
|
+
# empty body. @result doubling as the closed flag depends on this.
|
|
124
|
+
@result = if @timed_out
|
|
125
|
+
send_final
|
|
126
|
+
else
|
|
127
|
+
begin
|
|
128
|
+
outbound = final_stream_activity
|
|
129
|
+
Api::SentActivity.merge(outbound, send_with_retry(outbound, @send_retry))
|
|
130
|
+
rescue StreamTimedOutError
|
|
131
|
+
# The final streamed send tripped the two-minute limit. Update the
|
|
132
|
+
# original message in place with the buffered content instead of
|
|
133
|
+
# posting a duplicate.
|
|
134
|
+
send_final
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@close_handlers.each { |handler| handler.call(@result) }
|
|
139
|
+
@result
|
|
140
|
+
ensure
|
|
141
|
+
@mutex.synchronize { reset_state } if @result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def reset_state
|
|
147
|
+
@id = nil
|
|
148
|
+
@text = +""
|
|
149
|
+
@sequence = 1
|
|
150
|
+
@channel_data = {}
|
|
151
|
+
@final_activity = nil
|
|
152
|
+
@queue = []
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def reset_for_next_stream
|
|
156
|
+
reset_state
|
|
157
|
+
@result = nil
|
|
158
|
+
@timed_out = false
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Nothing was ever sent, queued, or in flight for this cycle.
|
|
162
|
+
def no_content_pending?
|
|
163
|
+
@mutex.synchronize { @sequence == 1 && @queue.empty? && !@flushing }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Waits until the queue is drained, no flush is in progress, and the
|
|
167
|
+
# stream id has been assigned by the first chunk, with a total timeout.
|
|
168
|
+
def wait_for_flush
|
|
169
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @total_wait_timeout
|
|
170
|
+
|
|
171
|
+
loop do
|
|
172
|
+
done = @mutex.synchronize { @queue.empty? && !@flushing && !@id.nil? }
|
|
173
|
+
return true if done
|
|
174
|
+
return true if canceled
|
|
175
|
+
|
|
176
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
177
|
+
app.logger&.warn("Timeout while waiting for the stream queue to flush, cannot close stream")
|
|
178
|
+
return false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
sleep(@poll_interval)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def run_flusher
|
|
186
|
+
loop do
|
|
187
|
+
flush_cycle
|
|
188
|
+
|
|
189
|
+
done = @mutex.synchronize do
|
|
190
|
+
if @queue.empty?
|
|
191
|
+
@flusher = nil
|
|
192
|
+
true
|
|
193
|
+
else
|
|
194
|
+
false
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
break if done
|
|
198
|
+
|
|
199
|
+
sleep(@flush_interval)
|
|
200
|
+
end
|
|
201
|
+
ensure
|
|
202
|
+
@mutex.synchronize { @flusher = nil if @flusher.equal?(Thread.current) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Drains the whole queue under the mutex, then sends outside it so emit
|
|
206
|
+
# never blocks on HTTP. Send failures are classified (sticky canceled /
|
|
207
|
+
# timed_out flags) and logged, not raised: errors surface from close,
|
|
208
|
+
# matching the TypeScript and Python flush behavior.
|
|
209
|
+
def flush_cycle
|
|
210
|
+
@mutex.synchronize { @flushing = true }
|
|
211
|
+
|
|
212
|
+
informative_updates = []
|
|
213
|
+
text_chunk = nil
|
|
214
|
+
|
|
215
|
+
@mutex.synchronize do
|
|
216
|
+
while (activity = @queue.shift)
|
|
217
|
+
if activity["type"] == "message"
|
|
218
|
+
@text << activity["text"].to_s if activity["text"]
|
|
219
|
+
@final_activity = activity
|
|
220
|
+
elsif informative_update?(activity) && @text.empty?
|
|
221
|
+
informative_updates << activity
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
@channel_data = merge_channel_data(activity["channelData"]) if activity["channelData"]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
text_chunk = @text.dup unless @text.empty?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Once the stream has timed out, stop sending chunks for this cycle;
|
|
231
|
+
# close sends the buffered content by updating the message in place.
|
|
232
|
+
return if @timed_out
|
|
233
|
+
|
|
234
|
+
informative_updates.each { |activity| send_stream_chunk(activity) }
|
|
235
|
+
send_stream_chunk("type" => "typing", "text" => text_chunk) if text_chunk
|
|
236
|
+
rescue StreamCancelledError
|
|
237
|
+
nil
|
|
238
|
+
rescue StandardError => error
|
|
239
|
+
app.logger&.error("stream flush failed: #{error.class}: #{error.message}")
|
|
240
|
+
ensure
|
|
241
|
+
@mutex.synchronize { @flushing = false }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def send_stream_chunk(activity)
|
|
245
|
+
return if @timed_out
|
|
246
|
+
|
|
247
|
+
body = activity.dup
|
|
248
|
+
body["id"] = @id if @id
|
|
249
|
+
|
|
250
|
+
channel_data = merge_channel_data(body["channelData"])
|
|
251
|
+
channel_data["streamId"] ||= @id if @id
|
|
252
|
+
# The chunk's own stream type wins; merged channel data must not leak a
|
|
253
|
+
# previous informative marker into regular streaming chunks.
|
|
254
|
+
channel_data["streamType"] = activity.dig("channelData", "streamType") || "streaming"
|
|
255
|
+
channel_data["streamSequence"] ||= @sequence
|
|
256
|
+
body["channelData"] = channel_data
|
|
257
|
+
|
|
258
|
+
body["entities"] = replace_streaminfo_entity(
|
|
259
|
+
body["entities"],
|
|
260
|
+
compact_hash(
|
|
261
|
+
"type" => "streaminfo",
|
|
262
|
+
"streamId" => @id,
|
|
263
|
+
"streamType" => channel_data["streamType"],
|
|
264
|
+
"streamSequence" => channel_data["streamSequence"]
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
result = begin
|
|
269
|
+
send_with_retry(body, @chunk_retry)
|
|
270
|
+
rescue StreamTimedOutError
|
|
271
|
+
# A timed-out chunk is swallowed; close sends the buffered content by
|
|
272
|
+
# updating the original message in place.
|
|
273
|
+
return
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
sent = Api::SentActivity.merge(body, result)
|
|
277
|
+
@chunk_handlers.each { |handler| handler.call(sent) }
|
|
278
|
+
# close's wait loop reads @id under the mutex; write under it too so
|
|
279
|
+
# the assignment is visible on non-GIL Ruby implementations.
|
|
280
|
+
@mutex.synchronize do
|
|
281
|
+
@sequence += 1
|
|
282
|
+
@id ||= sent.id
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def final_stream_activity
|
|
287
|
+
activity = (@final_activity || { "type" => "message" }).dup
|
|
288
|
+
activity["type"] = "message"
|
|
289
|
+
activity["id"] = @id if @id
|
|
290
|
+
if !@text.empty? || activity.key?("text")
|
|
291
|
+
activity["text"] = @text
|
|
292
|
+
else
|
|
293
|
+
activity.delete("text")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
channel_data = merge_channel_data(activity["channelData"], "streamType" => "final")
|
|
297
|
+
channel_data.delete("streamSequence")
|
|
298
|
+
channel_data["streamId"] ||= @id if @id
|
|
299
|
+
activity["channelData"] = channel_data unless channel_data.empty?
|
|
300
|
+
|
|
301
|
+
activity["entities"] = replace_streaminfo_entity(
|
|
302
|
+
activity["entities"],
|
|
303
|
+
"type" => "streaminfo",
|
|
304
|
+
"streamId" => @id,
|
|
305
|
+
"streamType" => "final"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
activity
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Sends the buffered content as a plain final message. Drops the
|
|
312
|
+
# streaminfo entity and stream channel data so the send routes through
|
|
313
|
+
# update (reusing the stream id) instead of creating a duplicate message.
|
|
314
|
+
def send_final
|
|
315
|
+
activity = (@final_activity || { "type" => "message" }).dup
|
|
316
|
+
activity["type"] = "message"
|
|
317
|
+
activity["id"] = @id if @id
|
|
318
|
+
if !@text.empty? || activity.key?("text")
|
|
319
|
+
activity["text"] = @text
|
|
320
|
+
else
|
|
321
|
+
activity.delete("text")
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
entities = Array(activity["entities"]).reject do |entity|
|
|
325
|
+
entity.is_a?(Hash) && entity["type"] == "streaminfo"
|
|
326
|
+
end
|
|
327
|
+
entities.empty? ? activity.delete("entities") : activity["entities"] = entities
|
|
328
|
+
|
|
329
|
+
strip_stream_channel_data(activity, activity["channelData"] || {})
|
|
330
|
+
|
|
331
|
+
Api::SentActivity.merge(activity, send_with_retry(activity, @send_retry))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Removes the stream markers from channel data for the timed-out in-place
|
|
335
|
+
# final, so the send is treated as a normal message edit (.NET behavior).
|
|
336
|
+
def strip_stream_channel_data(body, channel_data)
|
|
337
|
+
remaining = channel_data.reject { |key, _value| STREAM_CHANNEL_DATA_KEYS.include?(key) }
|
|
338
|
+
remaining.empty? ? body.delete("channelData") : body["channelData"] = remaining
|
|
339
|
+
body
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Retries transient failures like the TypeScript and Python streamers.
|
|
343
|
+
# Typed stream errors are never retried; upstream also retries cancelled
|
|
344
|
+
# sends (their cancellation error sits outside the terminal hierarchy),
|
|
345
|
+
# but those attempts only re-raise after backoff delays, so Ruby skips
|
|
346
|
+
# them for the same visible behavior without the waits.
|
|
347
|
+
def send_with_retry(activity, options)
|
|
348
|
+
Common::Retry.call(**options, non_retryable: NON_RETRYABLE_ERRORS, logger: app.logger) do
|
|
349
|
+
send_activity(activity)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def send_activity(activity)
|
|
354
|
+
raise StreamCancelledError, "Stream has been cancelled." if canceled
|
|
355
|
+
|
|
356
|
+
body = activity.dup
|
|
357
|
+
body["from"] = conversation_reference.bot.to_h if conversation_reference.bot
|
|
358
|
+
body["conversation"] = conversation_reference.conversation.to_h
|
|
359
|
+
|
|
360
|
+
# Stream chunks and the streamed final carry a streaminfo entity and are
|
|
361
|
+
# always created; only the timed-out in-place final routes through update.
|
|
362
|
+
if body["id"] && !streaminfo_entity?(body)
|
|
363
|
+
app.api.conversations.update_activity(
|
|
364
|
+
conversation_reference.conversation_id,
|
|
365
|
+
body["id"],
|
|
366
|
+
body,
|
|
367
|
+
service_url: conversation_reference.service_url
|
|
368
|
+
)
|
|
369
|
+
else
|
|
370
|
+
app.api.conversations.create_activity(
|
|
371
|
+
conversation_reference.conversation_id,
|
|
372
|
+
body,
|
|
373
|
+
service_url: conversation_reference.service_url
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
rescue HttpError => error
|
|
377
|
+
raise_stream_error(error)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def raise_stream_error(error)
|
|
381
|
+
raise error unless error.status == 403
|
|
382
|
+
|
|
383
|
+
message = stream_error_message(error)
|
|
384
|
+
normalized = message.downcase
|
|
385
|
+
|
|
386
|
+
if normalized.include?("exceeded streaming time")
|
|
387
|
+
@timed_out = true
|
|
388
|
+
raise StreamTimedOutError, message.empty? ? "Stream exceeded the streaming time limit." : message
|
|
389
|
+
elsif normalized.include?("cancel")
|
|
390
|
+
@canceled = true
|
|
391
|
+
raise StreamCancelledError, message.empty? ? "Teams channel stopped the stream." : message
|
|
392
|
+
elsif normalized.include?("not allowed") && !normalized.include?("completed streamed message")
|
|
393
|
+
raise StreamNotAllowedError, message.empty? ? "Streaming is not allowed for the user or bot." : message
|
|
394
|
+
else
|
|
395
|
+
raise TerminalStreamError, message.empty? ? "Teams returned a streaming error." : message
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def stream_error_message(error)
|
|
400
|
+
body = error.body
|
|
401
|
+
return "" unless body.is_a?(Hash)
|
|
402
|
+
|
|
403
|
+
body.dig("error", "message").to_s
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def streaminfo_entity?(body)
|
|
407
|
+
Array(body["entities"]).any? do |entity|
|
|
408
|
+
entity.is_a?(Hash) && entity["type"] == "streaminfo"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def informative_update?(activity)
|
|
413
|
+
activity["type"] == "typing" && activity.dig("channelData", "streamType") == "informative"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def final_content?
|
|
417
|
+
return true unless @text.empty?
|
|
418
|
+
|
|
419
|
+
activity = @final_activity
|
|
420
|
+
return false unless activity
|
|
421
|
+
|
|
422
|
+
attachments = activity["attachments"]
|
|
423
|
+
suggested_actions = activity["suggestedActions"]
|
|
424
|
+
|
|
425
|
+
(attachments.respond_to?(:empty?) && !attachments.empty?) ||
|
|
426
|
+
(suggested_actions.respond_to?(:empty?) && !suggested_actions.empty?)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def normalize_activity(activity_or_text)
|
|
430
|
+
case activity_or_text
|
|
431
|
+
when String
|
|
432
|
+
Api::MessageActivity.new(activity_or_text).to_h
|
|
433
|
+
when Cards::AdaptiveCard
|
|
434
|
+
Api::MessageActivity.new.add_card(activity_or_text).to_h
|
|
435
|
+
when Hash
|
|
436
|
+
Common::Hashes.deep_stringify_keys(activity_or_text)
|
|
437
|
+
else
|
|
438
|
+
activity_or_text.respond_to?(:to_h) ? activity_or_text.to_h : activity_or_text
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def merge_channel_data(value, overrides = {})
|
|
443
|
+
(@channel_data || {}).merge(value || {}).merge(overrides)
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def replace_streaminfo_entity(entities, stream_entity)
|
|
447
|
+
remaining = Array(entities).reject do |entity|
|
|
448
|
+
entity.is_a?(Hash) && entity["type"] == "streaminfo"
|
|
449
|
+
end
|
|
450
|
+
remaining << stream_entity
|
|
451
|
+
remaining
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
def compact_hash(hash)
|
|
455
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
456
|
+
result[key] = value unless value.nil?
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack/request"
|
|
5
|
+
|
|
6
|
+
module Teams
|
|
7
|
+
class RackApp
|
|
8
|
+
def initialize(app)
|
|
9
|
+
@app = app
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
FUNCTION_PATH = %r{\A/api/functions/([^/]+)\z}
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
request = Rack::Request.new(env)
|
|
16
|
+
|
|
17
|
+
if request.post? && (function = FUNCTION_PATH.match(request.path_info))
|
|
18
|
+
body = request.body.read
|
|
19
|
+
payload = body.empty? ? {} : JSON.parse(body)
|
|
20
|
+
return rack_response(@app.process_function(function[1], payload, env:))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
return not_found unless request.post? && request.path_info == @app.messaging_endpoint
|
|
24
|
+
|
|
25
|
+
body = request.body.read
|
|
26
|
+
payload = body.empty? ? {} : JSON.parse(body)
|
|
27
|
+
response = @app.process_inbound(payload, env:)
|
|
28
|
+
rack_response(response)
|
|
29
|
+
rescue JSON::ParserError
|
|
30
|
+
@app.logger.warn("Rejected Teams request: invalid JSON body")
|
|
31
|
+
rack_response(Response.new(status: 400, body: { error: "invalid JSON" }))
|
|
32
|
+
rescue BadRequestError, AuthenticationError => error
|
|
33
|
+
@app.logger.warn("Rejected Teams request (#{error_status(error)}): #{error.message}")
|
|
34
|
+
rack_response(Response.new(status: error_status(error), body: { error: error.message }))
|
|
35
|
+
rescue StandardError => error
|
|
36
|
+
@app.logger.error("Error processing Teams activity: #{error.class}: #{error.message}")
|
|
37
|
+
rack_response(Response.new(status: 500, body: { error: "Internal server error" }))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def not_found
|
|
43
|
+
[404, { "content-type" => "text/plain" }, ["Not Found"]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rack_response(response)
|
|
47
|
+
body = response.body
|
|
48
|
+
body = body.nil? ? "" : JSON.generate(body)
|
|
49
|
+
headers = { "content-type" => "application/json" }.merge(response.headers)
|
|
50
|
+
[response.status, headers, [body]]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def error_status(error)
|
|
54
|
+
case error
|
|
55
|
+
when AuthenticationError
|
|
56
|
+
401
|
|
57
|
+
else
|
|
58
|
+
400
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/teams/router.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
class Router
|
|
5
|
+
Route = Struct.new(:name, :selector, :handler, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@routes = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def use(&block)
|
|
12
|
+
register(nil, ->(_activity) { true }, &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def on(name, &block)
|
|
16
|
+
register(name.to_s, route_selector(name.to_s), &block)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def on_message(pattern = nil, &block)
|
|
20
|
+
selector = lambda do |activity|
|
|
21
|
+
next false unless activity.message?
|
|
22
|
+
next true if pattern.nil?
|
|
23
|
+
|
|
24
|
+
case pattern
|
|
25
|
+
when Regexp
|
|
26
|
+
pattern.match?(activity.text.to_s)
|
|
27
|
+
else
|
|
28
|
+
activity.text.to_s == pattern.to_s
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
register("message", selector, &block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on_message_update(&block)
|
|
36
|
+
register("messageUpdate", route_selector("messageUpdate"), &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_edit_message(&block)
|
|
40
|
+
register("edit_message", message_update_event_selector("editMessage"), &block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def on_undelete_message(&block)
|
|
44
|
+
register("undelete_message", message_update_event_selector("undeleteMessage"), &block)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Matches task/fetch invokes; with a dialog_id, only those whose card
|
|
48
|
+
# action data carries that "dialog_id" value.
|
|
49
|
+
def on_dialog_open(dialog_id = nil, &block)
|
|
50
|
+
register("dialog.open", dialog_selector("task/fetch", "dialog_id", dialog_id), &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Matches task/submit invokes; with an action, only those whose card
|
|
54
|
+
# action data carries that "action" value.
|
|
55
|
+
def on_dialog_submit(action = nil, &block)
|
|
56
|
+
register("dialog.submit", dialog_selector("task/submit", "action", action), &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def on_signin_token_exchange(&block)
|
|
60
|
+
register("signin.token-exchange", invoke_selector("signin/tokenExchange"), &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_signin_verify_state(&block)
|
|
64
|
+
register("signin.verify-state", invoke_selector("signin/verifyState"), &block)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def on_signin_failure(&block)
|
|
68
|
+
register("signin.failure", invoke_selector("signin/failure"), &block)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def on_message_submit(&block)
|
|
72
|
+
register("message.submit", invoke_selector("message/submitAction"), &block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# message/submitAction invokes whose actionName is "feedback" - the
|
|
76
|
+
# submissions from add_feedback's thumbs up/down UI.
|
|
77
|
+
def on_message_submit_feedback(&block)
|
|
78
|
+
selector = lambda do |activity|
|
|
79
|
+
activity.invoke? && activity.name == "message/submitAction" &&
|
|
80
|
+
activity.raw.dig("value", "actionName") == "feedback"
|
|
81
|
+
end
|
|
82
|
+
register("message.submit.feedback", selector, &block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def on_meeting_start(&block)
|
|
86
|
+
register("meeting_start", event_selector("application/vnd.microsoft.meetingStart"), &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def on_meeting_end(&block)
|
|
90
|
+
register("meeting_end", event_selector("application/vnd.microsoft.meetingEnd"), &block)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Message extension (compose extension) invoke routes, using the same
|
|
94
|
+
# route names as the TypeScript and Python SDKs.
|
|
95
|
+
MESSAGE_EXTENSION_ROUTES = {
|
|
96
|
+
"message.ext.query" => "composeExtension/query",
|
|
97
|
+
"message.ext.select-item" => "composeExtension/selectItem",
|
|
98
|
+
"message.ext.submit" => "composeExtension/submitAction",
|
|
99
|
+
"message.ext.open" => "composeExtension/fetchTask",
|
|
100
|
+
"message.ext.query-link" => "composeExtension/queryLink",
|
|
101
|
+
"message.ext.anon-query-link" => "composeExtension/anonymousQueryLink",
|
|
102
|
+
"message.ext.query-settings-url" => "composeExtension/querySettingUrl",
|
|
103
|
+
"message.ext.setting" => "composeExtension/setting",
|
|
104
|
+
"message.ext.card-button-clicked" => "composeExtension/onCardButtonClicked"
|
|
105
|
+
}.freeze
|
|
106
|
+
|
|
107
|
+
MESSAGE_EXTENSION_HANDLER_METHODS = MESSAGE_EXTENSION_ROUTES.keys.to_h do |route_name|
|
|
108
|
+
["on_#{route_name.sub("message.ext.", "message_ext_").tr("-", "_")}", route_name]
|
|
109
|
+
end.freeze
|
|
110
|
+
|
|
111
|
+
MESSAGE_EXTENSION_HANDLER_METHODS.each do |method_name, route_name|
|
|
112
|
+
define_method(method_name) do |&block|
|
|
113
|
+
register(route_name, invoke_selector(MESSAGE_EXTENSION_ROUTES.fetch(route_name)), &block)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def matching(activity)
|
|
118
|
+
@routes.select { |route| route.selector.call(activity) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def register(name, selector, &block)
|
|
124
|
+
raise ArgumentError, "handler block is required" unless block
|
|
125
|
+
|
|
126
|
+
@routes << Route.new(name:, selector:, handler: block)
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def route_selector(name)
|
|
131
|
+
lambda do |activity|
|
|
132
|
+
return true if name == "activity"
|
|
133
|
+
return true if name == activity.type
|
|
134
|
+
return true if name == "message" && activity.message?
|
|
135
|
+
return true if name == "typing" && activity.typing?
|
|
136
|
+
return true if name == "invoke" && activity.invoke?
|
|
137
|
+
return true if name == "suggested-action.submit" && activity.suggested_action_submit?
|
|
138
|
+
|
|
139
|
+
if activity.install_update?
|
|
140
|
+
return true if name == "install.#{activity.raw["action"]}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
false
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def invoke_selector(invoke_name)
|
|
148
|
+
->(activity) { activity.invoke? && activity.name == invoke_name }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def event_selector(event_name)
|
|
152
|
+
->(activity) { activity.type == "event" && activity.name == event_name }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def dialog_selector(invoke_name, key, expected)
|
|
156
|
+
lambda do |activity|
|
|
157
|
+
next false unless activity.invoke? && activity.name == invoke_name
|
|
158
|
+
next true if expected.nil?
|
|
159
|
+
|
|
160
|
+
data = activity.raw.dig("value", "data")
|
|
161
|
+
data.is_a?(Hash) && data[key] == expected
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def message_update_event_selector(event_type)
|
|
166
|
+
lambda do |activity|
|
|
167
|
+
activity.message_update? && activity.channel_data.event_type == event_type
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|