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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +500 -0
  5. data/docs/README.md +40 -0
  6. data/docs/essentials/README.md +13 -0
  7. data/docs/essentials/api-client.md +62 -0
  8. data/docs/essentials/app-authentication.md +27 -0
  9. data/docs/essentials/app-basics.md +50 -0
  10. data/docs/essentials/graph.md +41 -0
  11. data/docs/essentials/on-activity.md +72 -0
  12. data/docs/essentials/on-event.md +29 -0
  13. data/docs/essentials/proactive-messaging.md +44 -0
  14. data/docs/essentials/sending-messages.md +76 -0
  15. data/docs/essentials/sovereign-cloud.md +26 -0
  16. data/docs/getting-started/README.md +7 -0
  17. data/docs/getting-started/code-basics.md +61 -0
  18. data/docs/getting-started/quickstart.md +54 -0
  19. data/docs/getting-started/running-in-teams.md +44 -0
  20. data/docs/in-depth-guides/README.md +14 -0
  21. data/docs/in-depth-guides/adaptive-cards.md +62 -0
  22. data/docs/in-depth-guides/dialogs.md +77 -0
  23. data/docs/in-depth-guides/feedback.md +29 -0
  24. data/docs/in-depth-guides/meeting-events.md +27 -0
  25. data/docs/in-depth-guides/message-extensions.md +77 -0
  26. data/docs/in-depth-guides/message-reactions.md +29 -0
  27. data/docs/in-depth-guides/observability.md +28 -0
  28. data/docs/in-depth-guides/streaming.md +52 -0
  29. data/docs/in-depth-guides/tabs.md +59 -0
  30. data/docs/in-depth-guides/user-authentication.md +60 -0
  31. data/lib/teams/activity.rb +160 -0
  32. data/lib/teams/activity_context.rb +278 -0
  33. data/lib/teams/api/account.rb +90 -0
  34. data/lib/teams/api/activity_value.rb +49 -0
  35. data/lib/teams/api/bot_sign_in_client.rb +54 -0
  36. data/lib/teams/api/channel_data.rb +86 -0
  37. data/lib/teams/api/channel_info.rb +19 -0
  38. data/lib/teams/api/citation_appearance.rb +83 -0
  39. data/lib/teams/api/client.rb +28 -0
  40. data/lib/teams/api/conversation_account.rb +40 -0
  41. data/lib/teams/api/conversation_client.rb +156 -0
  42. data/lib/teams/api/conversation_reference.rb +83 -0
  43. data/lib/teams/api/conversation_resource.rb +19 -0
  44. data/lib/teams/api/meeting_client.rb +57 -0
  45. data/lib/teams/api/meeting_info.rb +26 -0
  46. data/lib/teams/api/meeting_notification_response.rb +29 -0
  47. data/lib/teams/api/meeting_participant.rb +33 -0
  48. data/lib/teams/api/message_activity.rb +201 -0
  49. data/lib/teams/api/message_extension.rb +110 -0
  50. data/lib/teams/api/model.rb +49 -0
  51. data/lib/teams/api/notification_info.rb +28 -0
  52. data/lib/teams/api/paged_members_result.rb +16 -0
  53. data/lib/teams/api/quoted_reply_entity.rb +65 -0
  54. data/lib/teams/api/reaction_client.rb +34 -0
  55. data/lib/teams/api/sent_activity.rb +48 -0
  56. data/lib/teams/api/task_module.rb +83 -0
  57. data/lib/teams/api/team_client.rb +45 -0
  58. data/lib/teams/api/team_details.rb +36 -0
  59. data/lib/teams/api/team_info.rb +44 -0
  60. data/lib/teams/api/tenant_info.rb +11 -0
  61. data/lib/teams/api/token.rb +81 -0
  62. data/lib/teams/api/typing_activity.rb +19 -0
  63. data/lib/teams/api/user_client.rb +83 -0
  64. data/lib/teams/app.rb +602 -0
  65. data/lib/teams/auth/client_secret_credentials.rb +7 -0
  66. data/lib/teams/auth/jwt_validator.rb +148 -0
  67. data/lib/teams/auth/token.rb +39 -0
  68. data/lib/teams/auth/token_manager.rb +68 -0
  69. data/lib/teams/cards/generated.rb +1764 -0
  70. data/lib/teams/cards/generated_base.rb +105 -0
  71. data/lib/teams/cards.rb +81 -0
  72. data/lib/teams/cloud_environment.rb +41 -0
  73. data/lib/teams/common/hashes.rb +20 -0
  74. data/lib/teams/common/http_client.rb +111 -0
  75. data/lib/teams/common/retry.rb +31 -0
  76. data/lib/teams/errors.rb +50 -0
  77. data/lib/teams/function_context.rb +82 -0
  78. data/lib/teams/graph/client.rb +73 -0
  79. data/lib/teams/http_stream.rb +460 -0
  80. data/lib/teams/rack_app.rb +62 -0
  81. data/lib/teams/response.rb +9 -0
  82. data/lib/teams/router.rb +171 -0
  83. data/lib/teams/storage/memory_store.rb +27 -0
  84. data/lib/teams/version.rb +5 -0
  85. data/lib/teams.rb +70 -0
  86. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ Response = Struct.new(:status, :body, :headers, keyword_init: true) do
5
+ def initialize(status: 200, body: nil, headers: {})
6
+ super(status:, body:, headers:)
7
+ end
8
+ end
9
+ end
@@ -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