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,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Teams
|
|
6
|
+
module Cards
|
|
7
|
+
# Base class for the generated Adaptive Card models. Serialization matches
|
|
8
|
+
# the other Teams SDKs' generated card classes: non-nil fields (including
|
|
9
|
+
# defaults) are emitted under their camelCase aliases, unknown extra
|
|
10
|
+
# fields pass through verbatim.
|
|
11
|
+
class GeneratedCard
|
|
12
|
+
OMIT = Object.new.freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def card_fields
|
|
16
|
+
@card_fields ||= superclass.respond_to?(:card_fields) ? superclass.card_fields.dup : {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def field(name, alias_name, default = nil, mutable: false)
|
|
20
|
+
card_fields[name] = { alias: alias_name, default:, mutable: }
|
|
21
|
+
|
|
22
|
+
define_method(name) { @values[name] }
|
|
23
|
+
define_method("#{name}=") { |value| @values[name] = value }
|
|
24
|
+
define_method("with_#{name}") do |value|
|
|
25
|
+
@values[name] = value
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Ruby convenience preserved from the original hand-written classes:
|
|
31
|
+
# allows the first constructor argument to set the given field.
|
|
32
|
+
def positional_field(name)
|
|
33
|
+
define_method(:initialize) do |positional = OMIT, **values|
|
|
34
|
+
values[name] = positional unless positional.equal?(OMIT)
|
|
35
|
+
super(**values)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Same, for classes whose hand-written predecessors took a splat.
|
|
40
|
+
def splat_field(name)
|
|
41
|
+
define_method(:initialize) do |*positional, **values|
|
|
42
|
+
values[name] = positional.flatten unless positional.empty?
|
|
43
|
+
super(**values)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def array_adder(method_name, field_name)
|
|
48
|
+
define_method(method_name) do |item|
|
|
49
|
+
(@values[field_name] ||= []) << item
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(**values)
|
|
56
|
+
@values = {}
|
|
57
|
+
self.class.card_fields.each do |name, spec|
|
|
58
|
+
@values[name] = if values.key?(name)
|
|
59
|
+
values.delete(name)
|
|
60
|
+
elsif spec[:mutable]
|
|
61
|
+
deep_dup(spec[:default])
|
|
62
|
+
else
|
|
63
|
+
spec[:default]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
values.each { |key, value| @values[key] = value }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
@values.each_with_object({}) do |(name, value), body|
|
|
71
|
+
next if value.nil?
|
|
72
|
+
|
|
73
|
+
spec = self.class.card_fields[name]
|
|
74
|
+
body[spec ? spec[:alias] : camelize(name.to_s)] = serialize(value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_json(*args)
|
|
79
|
+
JSON.generate(to_h, *args)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def serialize(value)
|
|
85
|
+
case value
|
|
86
|
+
when Array
|
|
87
|
+
value.map { |item| serialize(item) }
|
|
88
|
+
when Hash
|
|
89
|
+
value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = serialize(item) }
|
|
90
|
+
else
|
|
91
|
+
value.respond_to?(:to_h) && !value.is_a?(Numeric) ? value.to_h : value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def deep_dup(value)
|
|
96
|
+
Marshal.load(Marshal.dump(value))
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def camelize(key)
|
|
100
|
+
parts = key.split("_")
|
|
101
|
+
parts.first + parts.drop(1).map(&:capitalize).join
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/teams/cards.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "cards/generated_base"
|
|
5
|
+
|
|
6
|
+
module Teams
|
|
7
|
+
module Cards
|
|
8
|
+
class CardObject
|
|
9
|
+
attr_reader :options
|
|
10
|
+
|
|
11
|
+
def initialize(**options)
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
compact_hash(serialize_options(options))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def to_json(*args)
|
|
20
|
+
JSON.generate(to_h, *args)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with_options(options = nil, **kwargs)
|
|
24
|
+
options = (options || {}).merge(kwargs)
|
|
25
|
+
options.each { |key, value| with_option(key, value) }
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def with_option(key, value)
|
|
32
|
+
options[key] = value
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def serialize(value)
|
|
37
|
+
case value
|
|
38
|
+
when nil
|
|
39
|
+
nil
|
|
40
|
+
when Array
|
|
41
|
+
value.map { |item| serialize(item) }
|
|
42
|
+
when Hash
|
|
43
|
+
serialize_options(value)
|
|
44
|
+
else
|
|
45
|
+
value.respond_to?(:to_h) ? value.to_h : value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def serialize_options(values)
|
|
50
|
+
values.each_with_object({}) do |(key, value), hash|
|
|
51
|
+
hash[json_key(key)] = serialize(value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def compact_hash(hash)
|
|
56
|
+
hash.reject { |_key, value| value.nil? }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def json_key(key)
|
|
60
|
+
return key if key.is_a?(String)
|
|
61
|
+
|
|
62
|
+
case key
|
|
63
|
+
when :schema
|
|
64
|
+
"$schema"
|
|
65
|
+
when :choices_data
|
|
66
|
+
"choices.data"
|
|
67
|
+
when :grid_area
|
|
68
|
+
"grid.area"
|
|
69
|
+
else
|
|
70
|
+
camelize(key)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def camelize(key)
|
|
75
|
+
parts = key.to_s.split("_")
|
|
76
|
+
parts.first + parts.drop(1).map(&:capitalize).join
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
CloudEnvironment = Struct.new(
|
|
5
|
+
:login_endpoint,
|
|
6
|
+
:login_tenant,
|
|
7
|
+
:bot_scope,
|
|
8
|
+
:token_service_url,
|
|
9
|
+
:open_id_metadata_url,
|
|
10
|
+
:token_issuer,
|
|
11
|
+
:graph_scope,
|
|
12
|
+
keyword_init: true
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
PUBLIC_CLOUD = CloudEnvironment.new(
|
|
16
|
+
login_endpoint: "https://login.microsoftonline.com",
|
|
17
|
+
login_tenant: "botframework.com",
|
|
18
|
+
bot_scope: "https://api.botframework.com/.default",
|
|
19
|
+
token_service_url: "https://token.botframework.com",
|
|
20
|
+
open_id_metadata_url: "https://login.botframework.com/v1/.well-known/openidconfiguration",
|
|
21
|
+
token_issuer: "https://api.botframework.com",
|
|
22
|
+
graph_scope: "https://graph.microsoft.com/.default"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
module CloudEnvironments
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def public
|
|
29
|
+
PUBLIC_CLOUD
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def from_name(name)
|
|
33
|
+
case name.to_s.downcase
|
|
34
|
+
when "", "public"
|
|
35
|
+
public
|
|
36
|
+
else
|
|
37
|
+
raise ConfigurationError, "unsupported cloud environment: #{name.inspect}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
module Common
|
|
5
|
+
module Hashes
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def deep_stringify_keys(value)
|
|
9
|
+
case value
|
|
10
|
+
when Hash
|
|
11
|
+
value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = deep_stringify_keys(item) }
|
|
12
|
+
when Array
|
|
13
|
+
value.map { |item| deep_stringify_keys(item) }
|
|
14
|
+
else
|
|
15
|
+
value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Teams
|
|
7
|
+
module Common
|
|
8
|
+
class HttpClient
|
|
9
|
+
attr_reader :base_url, :headers, :token
|
|
10
|
+
|
|
11
|
+
def initialize(base_url: nil, headers: {}, token: nil, connection: nil)
|
|
12
|
+
@base_url = base_url
|
|
13
|
+
@headers = headers
|
|
14
|
+
@token = token
|
|
15
|
+
@connection = connection
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def get(path, headers: {}, params: nil)
|
|
19
|
+
request(:get, path, headers:, params:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post(path, json: nil, body: nil, headers: {}, params: nil)
|
|
23
|
+
request(:post, path, json:, body:, headers:, params:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def patch(path, json: nil, body: nil, headers: {}, params: nil)
|
|
27
|
+
request(:patch, path, json:, body:, headers:, params:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def put(path, json: nil, body: nil, headers: {}, params: nil)
|
|
31
|
+
request(:put, path, json:, body:, headers:, params:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def delete(path, headers: {}, params: nil)
|
|
35
|
+
request(:delete, path, headers:, params:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def request(method, path, json: nil, body: nil, headers: {}, params: nil)
|
|
39
|
+
resolved_token = resolve_token
|
|
40
|
+
response = connection.public_send(method) do |request|
|
|
41
|
+
request.url(path)
|
|
42
|
+
request.params.update(params) if params
|
|
43
|
+
request.headers.update(default_headers)
|
|
44
|
+
request.headers.update(headers)
|
|
45
|
+
request.headers["Authorization"] = "Bearer #{resolved_token}" if resolved_token
|
|
46
|
+
|
|
47
|
+
if json
|
|
48
|
+
request.headers["Content-Type"] ||= "application/json"
|
|
49
|
+
request.body = JSON.generate(json)
|
|
50
|
+
elsif body
|
|
51
|
+
request.body = body
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
parse_response(response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def clone(base_url: nil, headers: nil, token: nil)
|
|
59
|
+
self.class.new(
|
|
60
|
+
base_url: base_url || self.base_url,
|
|
61
|
+
headers: self.headers.merge(headers || {}),
|
|
62
|
+
token: token || self.token,
|
|
63
|
+
connection: @connection
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def connection
|
|
70
|
+
@connection ||= Faraday.new(url: base_url) do |faraday|
|
|
71
|
+
# Flat params: arrays encode as repeated plain keys (a=1&a=2),
|
|
72
|
+
# the shape the Bot Framework token service expects.
|
|
73
|
+
faraday.options.params_encoder = Faraday::FlatParamsEncoder
|
|
74
|
+
faraday.adapter Faraday.default_adapter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def default_headers
|
|
79
|
+
{ "User-Agent" => "teams_rb/#{Teams::VERSION}" }.merge(headers)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_token
|
|
83
|
+
@resolved_token = token.respond_to?(:call) ? token.call : token
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_response(response)
|
|
87
|
+
body = response.body.to_s
|
|
88
|
+
parsed = begin
|
|
89
|
+
body.empty? ? nil : JSON.parse(body)
|
|
90
|
+
rescue JSON::ParserError
|
|
91
|
+
body
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
if response.status < 200 || response.status >= 300
|
|
95
|
+
message = "HTTP request failed with status #{response.status}"
|
|
96
|
+
message = "#{message}: #{parsed}" if parsed && parsed != ""
|
|
97
|
+
|
|
98
|
+
raise HttpError.new(
|
|
99
|
+
message,
|
|
100
|
+
status: response.status,
|
|
101
|
+
headers: response.headers,
|
|
102
|
+
body: parsed || body,
|
|
103
|
+
request: response.env&.request
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
parsed
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
module Common
|
|
5
|
+
module Retry
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Retries the block with exponential backoff, mirroring the retry
|
|
9
|
+
# helpers the TypeScript and Python SDKs use for stream sends.
|
|
10
|
+
# jitter :full randomizes each wait between 0 and the capped delay;
|
|
11
|
+
# :none waits the capped delay exactly.
|
|
12
|
+
def call(max_attempts: 5, delay: 0.5, max_delay: 30.0, jitter: :full, non_retryable: [], logger: nil)
|
|
13
|
+
attempt = 1
|
|
14
|
+
begin
|
|
15
|
+
yield
|
|
16
|
+
rescue *non_retryable
|
|
17
|
+
raise
|
|
18
|
+
rescue StandardError => error
|
|
19
|
+
raise if attempt >= max_attempts
|
|
20
|
+
|
|
21
|
+
capped = [delay * (2**(attempt - 1)), max_delay].min
|
|
22
|
+
wait = jitter == :full ? rand * capped : capped
|
|
23
|
+
logger&.debug("retrying in #{format('%.2f', wait)}s (attempt #{attempt}/#{max_attempts}): #{error.message}")
|
|
24
|
+
sleep(wait)
|
|
25
|
+
attempt += 1
|
|
26
|
+
retry
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/teams/errors.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
|
|
8
|
+
class AuthenticationError < Error; end
|
|
9
|
+
|
|
10
|
+
class BadRequestError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when Teams cancels a stream (for example, the user pressed Stop)
|
|
13
|
+
# or when a stream operation is attempted after cancellation.
|
|
14
|
+
class StreamCancelledError < Error; end
|
|
15
|
+
|
|
16
|
+
# Base class for terminal streaming errors (HTTP 403) that should not be retried.
|
|
17
|
+
# https://learn.microsoft.com/en-us/microsoftteams/platform/bots/streaming-ux?tabs=csharp#error-codes
|
|
18
|
+
class TerminalStreamError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised when the bot failed to complete streaming within the two-minute limit.
|
|
21
|
+
class StreamTimedOutError < TerminalStreamError; end
|
|
22
|
+
|
|
23
|
+
# Raised when streaming is not allowed for this user or bot.
|
|
24
|
+
class StreamNotAllowedError < TerminalStreamError; end
|
|
25
|
+
|
|
26
|
+
# A failed Microsoft Graph request; code carries the Graph error code
|
|
27
|
+
# (e.g. "Authorization_RequestDenied") and body the full error payload.
|
|
28
|
+
class GraphError < Error
|
|
29
|
+
attr_reader :status, :code, :body
|
|
30
|
+
|
|
31
|
+
def initialize(message, status:, code: nil, body: nil)
|
|
32
|
+
super(message)
|
|
33
|
+
@status = status
|
|
34
|
+
@code = code
|
|
35
|
+
@body = body
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class HttpError < Error
|
|
40
|
+
attr_reader :status, :headers, :body, :request
|
|
41
|
+
|
|
42
|
+
def initialize(message, status:, headers:, body:, request: nil)
|
|
43
|
+
super(message)
|
|
44
|
+
@status = status
|
|
45
|
+
@headers = headers
|
|
46
|
+
@body = body
|
|
47
|
+
@request = request
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
# Context for a remote function call from a tab (POST /api/functions/{name}):
|
|
5
|
+
# the validated Entra identity, the Teams client context headers, and the
|
|
6
|
+
# request payload.
|
|
7
|
+
class FunctionContext
|
|
8
|
+
attr_reader :app, :function_name, :data, :app_session_id, :page_id, :auth_token,
|
|
9
|
+
:tenant_id, :user_id, :user_name, :app_id, :channel_id, :chat_id,
|
|
10
|
+
:meeting_id, :message_id, :sub_page_id, :team_id
|
|
11
|
+
|
|
12
|
+
def initialize(app:, function_name:, data:, app_session_id:, page_id:, auth_token:,
|
|
13
|
+
tenant_id:, user_id:, user_name:, app_id: nil, channel_id: nil,
|
|
14
|
+
chat_id: nil, meeting_id: nil, message_id: nil, sub_page_id: nil, team_id: nil)
|
|
15
|
+
@app = app
|
|
16
|
+
@function_name = function_name
|
|
17
|
+
@data = data
|
|
18
|
+
@app_session_id = app_session_id
|
|
19
|
+
@page_id = page_id
|
|
20
|
+
@auth_token = auth_token
|
|
21
|
+
@tenant_id = tenant_id
|
|
22
|
+
@user_id = user_id
|
|
23
|
+
@user_name = user_name
|
|
24
|
+
@app_id = app_id
|
|
25
|
+
@channel_id = channel_id
|
|
26
|
+
@chat_id = chat_id
|
|
27
|
+
@meeting_id = meeting_id
|
|
28
|
+
@message_id = message_id
|
|
29
|
+
@sub_page_id = sub_page_id
|
|
30
|
+
@team_id = team_id
|
|
31
|
+
@resolved = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def api
|
|
35
|
+
app.api
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def log
|
|
39
|
+
app.logger
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Resolves the conversation this call relates to, like the TypeScript and
|
|
43
|
+
# Python function contexts: chat/channel id when the user is a member of
|
|
44
|
+
# it; in personal scope, creates (or re-fetches) the 1:1 conversation.
|
|
45
|
+
# Returns nil when no conversation can be resolved.
|
|
46
|
+
def conversation_id
|
|
47
|
+
return @conversation_id if @resolved
|
|
48
|
+
|
|
49
|
+
@resolved = true
|
|
50
|
+
@conversation_id = resolve_conversation_id
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Posts into the resolved conversation (proactive send with the bot's
|
|
54
|
+
# identity).
|
|
55
|
+
def post(activity_or_text)
|
|
56
|
+
id = conversation_id
|
|
57
|
+
raise Error, "Unable to resolve a conversation for this function call" unless id
|
|
58
|
+
|
|
59
|
+
app.post(id, activity_or_text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def resolve_conversation_id
|
|
65
|
+
existing = chat_id || channel_id
|
|
66
|
+
|
|
67
|
+
if existing.nil?
|
|
68
|
+
conversation = api.conversations.create(
|
|
69
|
+
members: [{ "id" => user_id, "role" => "user", "name" => user_name }],
|
|
70
|
+
tenant_id: tenant_id
|
|
71
|
+
)
|
|
72
|
+
return conversation.id
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
member = api.conversations.get_member_by_id(existing, user_id)
|
|
76
|
+
member ? existing : nil
|
|
77
|
+
rescue HttpError => error
|
|
78
|
+
log&.warn("Could not resolve conversation for function call: #{error.message}")
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
module Graph
|
|
5
|
+
# Thin Microsoft Graph HTTP client, following the TypeScript SDK's core
|
|
6
|
+
# Graph client (Python/.NET delegate to the official Graph SDKs, which
|
|
7
|
+
# Ruby lacks a maintained equivalent of; TypeScript's generated endpoint
|
|
8
|
+
# packages are out of scope, so the raw request surface is the API here,
|
|
9
|
+
# like TypeScript's client.http escape hatch).
|
|
10
|
+
class Client
|
|
11
|
+
DEFAULT_BASE_URL_ROOT = "https://graph.microsoft.com"
|
|
12
|
+
|
|
13
|
+
attr_reader :base_url_root, :version, :http
|
|
14
|
+
|
|
15
|
+
# token: a string or a callable returning one (an app-only Graph token
|
|
16
|
+
# or a user token from sign-in). base_url_root overrides the Graph host
|
|
17
|
+
# for sovereign clouds (e.g. "https://graph.microsoft.us").
|
|
18
|
+
def initialize(token:, base_url_root: nil, version: "v1.0", http: nil)
|
|
19
|
+
@base_url_root = (base_url_root || DEFAULT_BASE_URL_ROOT).sub(%r{/+\z}, "")
|
|
20
|
+
@version = version
|
|
21
|
+
@http = http || Common::HttpClient.new(token:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(path, params: nil)
|
|
25
|
+
request { http.get(url(path), params:) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def post(path, json: nil, params: nil)
|
|
29
|
+
request { http.post(url(path), json: stringify(json), params:) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def patch(path, json: nil, params: nil)
|
|
33
|
+
request { http.patch(url(path), json: stringify(json), params:) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def put(path, json: nil, params: nil)
|
|
37
|
+
request { http.put(url(path), json: stringify(json), params:) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def delete(path, params: nil)
|
|
41
|
+
request { http.delete(url(path), params:) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def request
|
|
47
|
+
yield
|
|
48
|
+
rescue HttpError => error
|
|
49
|
+
graph_error = error.body.is_a?(Hash) ? error.body["error"] : nil
|
|
50
|
+
message = if graph_error.is_a?(Hash) && graph_error["message"]
|
|
51
|
+
"Graph request failed (#{error.status}): #{graph_error["message"]}"
|
|
52
|
+
else
|
|
53
|
+
"Graph request failed with status #{error.status}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
raise GraphError.new(
|
|
57
|
+
message,
|
|
58
|
+
status: error.status,
|
|
59
|
+
code: graph_error.is_a?(Hash) ? graph_error["code"] : nil,
|
|
60
|
+
body: error.body
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def url(path)
|
|
65
|
+
"#{base_url_root}/#{version}/#{path.to_s.sub(%r{\A/+}, "")}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stringify(json)
|
|
69
|
+
json.is_a?(Hash) ? Common::Hashes.deep_stringify_keys(json) : json
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|