teams_rb 2.0.1 → 2.0.2
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 +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +92 -435
- data/docs/essentials/app-authentication.md +1 -1
- data/docs/essentials/app-basics.md +2 -2
- data/docs/essentials/on-activity.md +14 -0
- data/docs/essentials/sending-messages.md +1 -0
- data/docs/getting-started/quickstart.md +6 -5
- data/docs/getting-started/running-in-teams.md +37 -14
- data/docs/in-depth-guides/adaptive-cards.md +17 -0
- data/docs/in-depth-guides/user-authentication.md +1 -1
- data/lib/teams/activity_context.rb +11 -28
- data/lib/teams/api/activity_value.rb +29 -0
- data/lib/teams/api/app_info.rb +15 -0
- data/lib/teams/api/channel_data.rb +5 -0
- data/lib/teams/api/conversation_client.rb +3 -1
- data/lib/teams/api/message_activity.rb +1 -0
- data/lib/teams/api/search.rb +41 -0
- data/lib/teams/app.rb +64 -9
- data/lib/teams/http_stream.rb +3 -0
- data/lib/teams/router.rb +38 -0
- data/lib/teams/version.rb +1 -1
- data/lib/teams.rb +2 -0
- metadata +3 -1
|
@@ -32,6 +32,20 @@ Each invoke family has named routes — their handler return values become the i
|
|
|
32
32
|
- Feedback: `on_message_submit_feedback` (thumbs up/down from `add_feedback` — [guide](../in-depth-guides/feedback.md)), `on_message_submit` for any `message/submitAction`
|
|
33
33
|
- `on_suggested_action_submit` for suggested-action submissions
|
|
34
34
|
|
|
35
|
+
## Conversation updates
|
|
36
|
+
|
|
37
|
+
`on_conversation_update` matches any `conversationUpdate` activity (members added/removed, channel and team changes). The channel/team lifecycle sub-events also have named routes, matched on `channelData.eventType`:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
teams.on_conversation_update { |ctx| } # any conversationUpdate activity
|
|
41
|
+
teams.on_channel_created { |ctx| ctx.post "Welcome to #{ctx.activity.channel_data.channel.name}!" }
|
|
42
|
+
teams.on_channel_deleted { |ctx| } # also: on_channel_renamed, on_channel_restored
|
|
43
|
+
teams.on_team_renamed { |ctx| } # also: on_team_archived, on_team_unarchived,
|
|
44
|
+
# on_team_deleted, on_team_hard_deleted, on_team_restored
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A generic `on_conversation_update` registered before a specific route sees the activity first; declare `|ctx, nxt|` and call `nxt.call` to continue to the specific route (see Middleware below).
|
|
48
|
+
|
|
35
49
|
## Meeting events
|
|
36
50
|
|
|
37
51
|
```ruby
|
|
@@ -32,6 +32,7 @@ ctx.update sent.id, "Done!"
|
|
|
32
32
|
```ruby
|
|
33
33
|
ctx.post Teams::Api::MessageActivity.new("**markdown**", text_format: "markdown")
|
|
34
34
|
# text_format: plain | markdown | xml | extendedmarkdown
|
|
35
|
+
# ("extendedmarkdown" is in public preview and may be subject to change)
|
|
35
36
|
|
|
36
37
|
ctx.post Teams::Api::MessageActivity.new("Quarterly numbers")
|
|
37
38
|
.add_ai_generated # "AI generated" badge
|
|
@@ -5,16 +5,17 @@ Build and run your first Teams bot in Ruby.
|
|
|
5
5
|
## Prerequisites
|
|
6
6
|
|
|
7
7
|
- Ruby 4.0+
|
|
8
|
-
- A Microsoft 365 tenant
|
|
8
|
+
- A Microsoft 365 tenant with custom app upload enabled ([developer program](https://developer.microsoft.com/microsoft-365/dev-program) tenants work)
|
|
9
9
|
- A tunnel for local development ([Dev Tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started), ngrok, or similar)
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
-
Add the
|
|
13
|
+
Add the SDK and a Rack server for this standalone example:
|
|
14
14
|
|
|
15
15
|
```ruby
|
|
16
16
|
# Gemfile
|
|
17
17
|
gem "teams_rb"
|
|
18
|
+
gem "puma"
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
```sh
|
|
@@ -38,15 +39,15 @@ end
|
|
|
38
39
|
run teams.to_rack
|
|
39
40
|
```
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
This example uses Puma; `teams_rb` itself does not require a server gem. Run it with:
|
|
42
43
|
|
|
43
44
|
```sh
|
|
44
|
-
CLIENT_ID=... CLIENT_SECRET=... TENANT_ID=... bundle exec
|
|
45
|
+
CLIENT_ID=... CLIENT_SECRET=... TENANT_ID=... bundle exec puma -p 3978
|
|
45
46
|
```
|
|
46
47
|
|
|
47
48
|
The app listens for Teams activities on `POST /api/messages`, validates every inbound request against Microsoft's signing keys, and echoes any message back as a quoted reply.
|
|
48
49
|
|
|
49
|
-
The three environment variables come from your bot registration — [Running in Teams](running-in-teams.md) walks through creating one and connecting Teams to your locally running bot. For local experiments without credentials, `Teams::App.new(
|
|
50
|
+
The three environment variables come from your bot registration — [Running in Teams](running-in-teams.md) walks through creating one and connecting Teams to your locally running bot. For local experiments without credentials, `Teams::App.new(dangerously_allow_unauthenticated_requests: true)` disables inbound validation (never use it beyond local testing; the app logs a loud warning when you do). The `DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS` environment variable sets the same option.
|
|
50
51
|
|
|
51
52
|
## Where to go next
|
|
52
53
|
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
# Running in Teams
|
|
2
2
|
|
|
3
|
-
Connect your locally running bot to a real Teams client.
|
|
3
|
+
Connect your locally running bot to a real Teams client with the [Teams CLI](https://www.npmjs.com/package/@microsoft/teams.cli).
|
|
4
4
|
|
|
5
|
-
## 1.
|
|
5
|
+
## 1. Install and sign in
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The CLI requires Node.js 20 or newer:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @microsoft/teams.cli
|
|
11
|
+
teams login
|
|
12
|
+
teams status
|
|
13
|
+
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
`teams status` should report that sideloading is enabled. If it is disabled, your tenant administrator must enable custom app upload before you can install the bot.
|
|
14
16
|
|
|
15
17
|
## 2. Start a tunnel
|
|
16
18
|
|
|
@@ -22,23 +24,44 @@ devtunnel port create teams-bot -p 3978
|
|
|
22
24
|
devtunnel host teams-bot
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
Note the tunnel URL,
|
|
27
|
+
Note the tunnel URL, such as `https://abc123-3978.euw.devtunnels.ms`. Dev Tunnels URLs are stable across restarts, so creation is a one-time step.
|
|
28
|
+
|
|
29
|
+
## 3. Register the app and bot
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
From your project directory, pass the public endpoint to the CLI:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
teams app create \
|
|
35
|
+
--name my-teams-ruby-bot \
|
|
36
|
+
--endpoint https://<your-tunnel>/api/messages
|
|
37
|
+
```
|
|
28
38
|
|
|
29
|
-
|
|
39
|
+
The CLI creates the app registration, manifest, bot, and Teams app. It prints the Teams App ID, an **Install in Teams** link, and the three values your Ruby app needs:
|
|
40
|
+
|
|
41
|
+
- `CLIENT_ID` — the bot's Microsoft App ID
|
|
42
|
+
- `CLIENT_SECRET` — the bot's client secret
|
|
43
|
+
- `TENANT_ID` — the Entra tenant ID
|
|
44
|
+
|
|
45
|
+
> If you plan to use [user authentication](../in-depth-guides/user-authentication.md), create an Azure-hosted bot with the CLI's `--azure`, `--subscription`, and `--resource-group` options. OAuth connection settings are not available for Teams-managed bots.
|
|
46
|
+
|
|
47
|
+
You can instead configure the same resources manually in the [Teams Developer Portal](https://dev.teams.microsoft.com/apps).
|
|
30
48
|
|
|
31
49
|
## 4. Run and install
|
|
32
50
|
|
|
51
|
+
Start the bot with the credentials printed by the CLI:
|
|
52
|
+
|
|
33
53
|
```sh
|
|
34
|
-
bundle exec
|
|
54
|
+
CLIENT_ID=... CLIENT_SECRET=... TENANT_ID=... bundle exec puma -p 3978
|
|
35
55
|
```
|
|
36
56
|
|
|
37
|
-
|
|
57
|
+
Open the **Install in Teams** link and send the bot a message. If you need the link again, run `teams app get <teams-app-id> --install-link`.
|
|
58
|
+
|
|
59
|
+
Inbound requests are JWT-validated with your real credentials; no `dangerously_allow_unauthenticated_requests` is needed behind the tunnel.
|
|
38
60
|
|
|
39
61
|
## Troubleshooting
|
|
40
62
|
|
|
41
|
-
- **No response in Teams**:
|
|
42
|
-
- **401s in your logs**: the requests are reaching you but failing validation
|
|
63
|
+
- **No response in Teams**: run `teams app doctor <teams-app-id>`, then check that the tunnel and server are running.
|
|
64
|
+
- **401s in your logs**: the requests are reaching you but failing validation, usually because the running app has credentials from a different registration.
|
|
65
|
+
- **Sideloading disabled**: ask your tenant administrator to enable custom app upload.
|
|
43
66
|
- **Teams caches aggressively**: after manifest changes, fully quit and reopen the Teams client.
|
|
44
67
|
- **At-least-once delivery**: Bot Framework redelivers activities when your bot errors or responds slowly. If a handler's side effects must not repeat, deduplicate by `ctx.activity.id`.
|
|
@@ -48,6 +48,23 @@ end
|
|
|
48
48
|
|
|
49
49
|
For richer action flows — opening a modal form from a card button — see [Dialogs](dialogs.md).
|
|
50
50
|
|
|
51
|
+
## Dynamic typeahead search
|
|
52
|
+
|
|
53
|
+
An `Input.ChoiceSet` with `choices.data` (`Data.Query`) fetches its choices from your bot as the user types. Teams sends an `application/search` invoke; answer it with `on_card_search`:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
teams.on_card_search do |ctx|
|
|
57
|
+
query = ctx.activity.value.query_text
|
|
58
|
+
Teams::Api::SearchResponse.new(
|
|
59
|
+
CITIES.grep(/#{Regexp.escape(query)}/i).map do |city|
|
|
60
|
+
Teams::Api::SearchInvokeResult.new(title: city, value: city)
|
|
61
|
+
end
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`ctx.activity.value` also carries `dataset` (the `Data.Query` dataset id, for cards with several dynamic inputs) and `query_options` with `skip`/`top` for paging large result sets.
|
|
67
|
+
|
|
51
68
|
## Regenerating
|
|
52
69
|
|
|
53
70
|
The card classes are generated; don't hand-edit `lib/teams/cards/generated.rb`. To regenerate after an upstream card-model change:
|
|
@@ -31,7 +31,7 @@ teams.on_sign_in do |ctx, token_response|
|
|
|
31
31
|
end
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
`ctx.sign_in` returns the token when the user is already signed in; otherwise it sends an OAuth card
|
|
34
|
+
`ctx.sign_in` returns the token when the user is already signed in; otherwise it sends an OAuth card and returns `nil`. In group chats and channels the card is delivered in the conversation as a targeted message only the requesting user sees; in channels the card always uses the sign-in button (silent SSO is not supported in channel scope). The SDK's **default handlers** then complete the sign-in invokes automatically — both the interactive card path (`signin/verifyState`) and silent SSO (`signin/tokenExchange`) — and fire `on_sign_in`. You don't handle the invokes yourself.
|
|
35
35
|
|
|
36
36
|
`ctx.sign_out` clears the stored token.
|
|
37
37
|
|
|
@@ -93,8 +93,10 @@ module Teams
|
|
|
93
93
|
|
|
94
94
|
# Starts the user sign-in flow: returns the token if the user is already
|
|
95
95
|
# signed in, otherwise sends an OAuth card and returns nil. In group
|
|
96
|
-
#
|
|
97
|
-
#
|
|
96
|
+
# chats and channels the card is a targeted message visible only to the
|
|
97
|
+
# requesting user; channels omit the token exchange resource because
|
|
98
|
+
# channel scope cannot do the silent SSO exchange, so the card renders
|
|
99
|
+
# the sign-in button instead.
|
|
98
100
|
def sign_in(connection_name: nil, oauth_card_text: "Please Sign In...", sign_in_button_text: "Sign In")
|
|
99
101
|
connection_name ||= app.default_connection_name
|
|
100
102
|
|
|
@@ -109,19 +111,6 @@ module Teams
|
|
|
109
111
|
# No token yet; continue with the OAuth card flow.
|
|
110
112
|
end
|
|
111
113
|
|
|
112
|
-
conversation_id = conversation_reference.conversation_id
|
|
113
|
-
if activity.conversation.is_group
|
|
114
|
-
one_on_one = api.conversations.create(
|
|
115
|
-
members: [activity.from.to_h],
|
|
116
|
-
tenant_id: activity.conversation.tenant_id
|
|
117
|
-
)
|
|
118
|
-
conversation_id = one_on_one.id
|
|
119
|
-
# Deliberately posts the plain text notice into the group (matching
|
|
120
|
-
# TypeScript and Python): group chats don't support SSO, so the card
|
|
121
|
-
# itself goes to the 1:1 below while the group sees only the notice.
|
|
122
|
-
post(oauth_card_text)
|
|
123
|
-
end
|
|
124
|
-
|
|
125
114
|
state = Base64.strict_encode64(JSON.generate(
|
|
126
115
|
"connectionName" => connection_name,
|
|
127
116
|
"conversation" => conversation_reference.to_h,
|
|
@@ -129,10 +118,14 @@ module Teams
|
|
|
129
118
|
))
|
|
130
119
|
resource = api.bots.sign_in.get_resource(state:)
|
|
131
120
|
|
|
121
|
+
is_channel = activity.conversation.conversation_type == "channel"
|
|
122
|
+
recipient = activity.from.to_h
|
|
123
|
+
recipient = recipient.merge("isTargeted" => true) if activity.conversation.is_group
|
|
124
|
+
|
|
132
125
|
card = {
|
|
133
126
|
"text" => oauth_card_text,
|
|
134
127
|
"connectionName" => connection_name,
|
|
135
|
-
"tokenExchangeResource" => resource.token_exchange_resource&.to_h,
|
|
128
|
+
"tokenExchangeResource" => (resource.token_exchange_resource&.to_h unless is_channel),
|
|
136
129
|
"tokenPostResource" => resource.token_post_resource&.to_h,
|
|
137
130
|
"buttons" => [
|
|
138
131
|
{ "type" => "signin", "title" => sign_in_button_text, "value" => resource.sign_in_link }
|
|
@@ -141,13 +134,13 @@ module Teams
|
|
|
141
134
|
|
|
142
135
|
payload = {
|
|
143
136
|
"type" => "message",
|
|
144
|
-
"recipient" =>
|
|
137
|
+
"recipient" => recipient,
|
|
145
138
|
"attachments" => [
|
|
146
139
|
{ "contentType" => "application/vnd.microsoft.card.oauth", "content" => card }
|
|
147
140
|
]
|
|
148
141
|
}
|
|
149
142
|
|
|
150
|
-
app.send_activity(
|
|
143
|
+
app.send_activity(conversation_reference, payload)
|
|
151
144
|
nil
|
|
152
145
|
end
|
|
153
146
|
|
|
@@ -167,16 +160,6 @@ module Teams
|
|
|
167
160
|
|
|
168
161
|
private
|
|
169
162
|
|
|
170
|
-
def sign_in_reference(conversation_id)
|
|
171
|
-
return conversation_reference if conversation_id == conversation_reference.conversation_id
|
|
172
|
-
|
|
173
|
-
Api::ConversationReference.from_h(
|
|
174
|
-
conversation_reference.to_h.merge(
|
|
175
|
-
"conversation" => conversation_reference.conversation.to_h.merge("id" => conversation_id)
|
|
176
|
-
)
|
|
177
|
-
)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
163
|
def incoming_targeted_sender
|
|
181
164
|
return nil unless activity.message?
|
|
182
165
|
return nil unless activity.recipient.is_targeted == true
|
|
@@ -44,6 +44,35 @@ module Teams
|
|
|
44
44
|
def end_time
|
|
45
45
|
read("EndTime", "endTime", "end_time")
|
|
46
46
|
end
|
|
47
|
+
|
|
48
|
+
# application/search invoke fields (Adaptive Card dynamic typeahead
|
|
49
|
+
# Input.ChoiceSet queries via choices.data / Data.Query).
|
|
50
|
+
def query_text
|
|
51
|
+
read("queryText", "query_text")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Pagination options; skip and top read from the nested wrapper.
|
|
55
|
+
def query_options
|
|
56
|
+
value = read("queryOptions", "query_options")
|
|
57
|
+
value.is_a?(Hash) ? ActivityValue.new(value) : value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def kind
|
|
61
|
+
read("kind")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The Data.Query dataset id authored on the Adaptive Card.
|
|
65
|
+
def dataset
|
|
66
|
+
read("dataset")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def skip
|
|
70
|
+
read("skip")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def top
|
|
74
|
+
read("top")
|
|
75
|
+
end
|
|
47
76
|
end
|
|
48
77
|
end
|
|
49
78
|
end
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
module Teams
|
|
4
4
|
module Api
|
|
5
5
|
class ChannelData < Model
|
|
6
|
+
def app
|
|
7
|
+
wrap(read("app"), AppInfo)
|
|
8
|
+
end
|
|
9
|
+
|
|
6
10
|
def tenant
|
|
7
11
|
wrap(read("tenant"), TenantInfo)
|
|
8
12
|
end
|
|
@@ -55,6 +59,7 @@ module Teams
|
|
|
55
59
|
|
|
56
60
|
def to_h
|
|
57
61
|
body = raw.dup
|
|
62
|
+
body["app"] = app.to_h if app
|
|
58
63
|
body["tenant"] = tenant.to_h if tenant
|
|
59
64
|
body["team"] = team.to_h if team
|
|
60
65
|
body["channel"] = channel.to_h if channel
|
|
@@ -41,7 +41,9 @@ module Teams
|
|
|
41
41
|
def reply_to_activity(conversation_id, activity_id, activity, service_url: nil)
|
|
42
42
|
body = activity_to_h(activity)
|
|
43
43
|
body = body.merge("replyToId" => activity_id) if body.is_a?(Hash)
|
|
44
|
-
|
|
44
|
+
# Replies POST to the base activities collection; the reply
|
|
45
|
+
# semantics ride entirely in the body's replyToId.
|
|
46
|
+
path = "/v3/conversations/#{escape(conversation_id)}/activities"
|
|
45
47
|
url = absolute(path, service_url:)
|
|
46
48
|
@logger&.debug("Teams API POST #{url}")
|
|
47
49
|
http.post(url, json: body)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module Teams
|
|
4
4
|
module Api
|
|
5
5
|
class MessageActivity
|
|
6
|
+
# "extendedmarkdown" is in public preview and may be subject to change.
|
|
6
7
|
TEXT_FORMATS = %w[plain markdown xml extendedmarkdown].freeze
|
|
7
8
|
FEEDBACK_MODES = %w[default custom].freeze
|
|
8
9
|
AI_MESSAGE_ENTITY_TYPE = "https://schema.org/Message"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
module Api
|
|
5
|
+
# A single application/search result: title is the display text, value
|
|
6
|
+
# is submitted when the user selects it.
|
|
7
|
+
class SearchInvokeResult
|
|
8
|
+
def initialize(title:, value:)
|
|
9
|
+
@title = title
|
|
10
|
+
@value = value
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ "title" => @title, "value" => @value }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Response body for application/search invokes (Adaptive Card dynamic
|
|
19
|
+
# typeahead Input.ChoiceSet queries). results accepts SearchInvokeResult
|
|
20
|
+
# objects or {title:, value:} hashes.
|
|
21
|
+
class SearchResponse
|
|
22
|
+
def initialize(results, status_code: 200)
|
|
23
|
+
@results = results
|
|
24
|
+
@status_code = status_code
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_h
|
|
28
|
+
{
|
|
29
|
+
"statusCode" => @status_code,
|
|
30
|
+
"type" => "application/vnd.microsoft.search.searchResponse",
|
|
31
|
+
"value" => {
|
|
32
|
+
"results" => Array(@results).map do |result|
|
|
33
|
+
body = result.is_a?(Hash) ? result : result.to_h
|
|
34
|
+
Common::Hashes.deep_stringify_keys(body)
|
|
35
|
+
end
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/teams/app.rb
CHANGED
|
@@ -5,6 +5,9 @@ require "logger"
|
|
|
5
5
|
module Teams
|
|
6
6
|
class App
|
|
7
7
|
DEFAULT_MESSAGING_ENDPOINT = "/api/messages"
|
|
8
|
+
DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR = "DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS"
|
|
9
|
+
TRUE_ENV_VALUES = %w[1 true yes on].freeze
|
|
10
|
+
FALSE_ENV_VALUES = %w[0 false no off].freeze
|
|
8
11
|
|
|
9
12
|
attr_reader :api, :logger, :storage, :messaging_endpoint, :default_connection_name, :graph
|
|
10
13
|
|
|
@@ -22,7 +25,8 @@ module Teams
|
|
|
22
25
|
storage: Storage::MemoryStore.new,
|
|
23
26
|
api: nil,
|
|
24
27
|
token_manager: nil,
|
|
25
|
-
|
|
28
|
+
dangerously_allow_unauthenticated_requests: nil,
|
|
29
|
+
skip_auth: nil,
|
|
26
30
|
messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT,
|
|
27
31
|
default_connection_name: "graph"
|
|
28
32
|
)
|
|
@@ -33,7 +37,9 @@ module Teams
|
|
|
33
37
|
@logger = logger
|
|
34
38
|
@storage = storage
|
|
35
39
|
@cloud = cloud
|
|
36
|
-
@
|
|
40
|
+
@dangerously_allow_unauthenticated_requests = resolve_unauthenticated_requests_option(
|
|
41
|
+
dangerously_allow_unauthenticated_requests, skip_auth
|
|
42
|
+
)
|
|
37
43
|
@messaging_endpoint = normalize_messaging_endpoint(messaging_endpoint)
|
|
38
44
|
@router = Router.new
|
|
39
45
|
|
|
@@ -140,6 +146,14 @@ module Teams
|
|
|
140
146
|
self
|
|
141
147
|
end
|
|
142
148
|
|
|
149
|
+
# application/search invokes from Adaptive Card dynamic typeahead
|
|
150
|
+
# Input.ChoiceSet queries; the handler's return (Api::SearchResponse or
|
|
151
|
+
# hash) becomes the invoke response body.
|
|
152
|
+
def on_card_search(&block)
|
|
153
|
+
@router.on_card_search(&block)
|
|
154
|
+
self
|
|
155
|
+
end
|
|
156
|
+
|
|
143
157
|
# Called with (ctx, token_response) whenever a sign-in completes through
|
|
144
158
|
# the default token-exchange or verify-state handlers.
|
|
145
159
|
def on_sign_in(&block)
|
|
@@ -206,6 +220,21 @@ module Teams
|
|
|
206
220
|
self
|
|
207
221
|
end
|
|
208
222
|
|
|
223
|
+
# conversationUpdate activities plus their channel/team lifecycle
|
|
224
|
+
# sub-events (on_channel_created, on_team_renamed, ...), routed by
|
|
225
|
+
# channelData.eventType with the Python method names.
|
|
226
|
+
def on_conversation_update(&block)
|
|
227
|
+
@router.on_conversation_update(&block)
|
|
228
|
+
self
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
Router::CONVERSATION_UPDATE_EVENTS.each_key do |method_name|
|
|
232
|
+
define_method(method_name) do |&block|
|
|
233
|
+
@router.public_send(method_name, &block)
|
|
234
|
+
self
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
209
238
|
# Message extension handlers (on_message_ext_query, on_message_ext_submit,
|
|
210
239
|
# on_message_ext_open, ...) route the composeExtension/* invokes with the
|
|
211
240
|
# TypeScript/Python route names. Handler return values (typed responses
|
|
@@ -326,7 +355,8 @@ module Teams
|
|
|
326
355
|
# ending in ctx.post must not leak the SentActivity into the response.
|
|
327
356
|
def invoke_response_body(result)
|
|
328
357
|
case result
|
|
329
|
-
when Api::TaskModuleResponse, Api::MessagingExtensionResponse, Api::MessagingExtensionActionResponse
|
|
358
|
+
when Api::TaskModuleResponse, Api::MessagingExtensionResponse, Api::MessagingExtensionActionResponse,
|
|
359
|
+
Api::SearchResponse
|
|
330
360
|
result.to_h
|
|
331
361
|
when Hash
|
|
332
362
|
Common::Hashes.deep_stringify_keys(result)
|
|
@@ -487,28 +517,53 @@ module Teams
|
|
|
487
517
|
end
|
|
488
518
|
end
|
|
489
519
|
|
|
520
|
+
# Resolves the auth-bypass setting like the Python SDK: the explicit
|
|
521
|
+
# option wins, then the deprecated skip_auth alias, then the env var,
|
|
522
|
+
# defaulting to false. Passing skip_auth always warns, even when the
|
|
523
|
+
# new option overrides it.
|
|
524
|
+
def resolve_unauthenticated_requests_option(explicit, skip_auth)
|
|
525
|
+
unless skip_auth.nil?
|
|
526
|
+
warn("skip_auth is deprecated; use dangerously_allow_unauthenticated_requests instead.", uplevel: 2)
|
|
527
|
+
end
|
|
528
|
+
return explicit unless explicit.nil?
|
|
529
|
+
return skip_auth unless skip_auth.nil?
|
|
530
|
+
|
|
531
|
+
parse_bool_env_var(DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR) || false
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def parse_bool_env_var(name)
|
|
535
|
+
value = ENV[name].to_s.strip.downcase
|
|
536
|
+
return nil if value.empty?
|
|
537
|
+
return true if TRUE_ENV_VALUES.include?(value)
|
|
538
|
+
return false if FALSE_ENV_VALUES.include?(value)
|
|
539
|
+
|
|
540
|
+
raise ArgumentError, "#{name} must be a boolean value: true/false, 1/0, yes/no, or on/off."
|
|
541
|
+
end
|
|
542
|
+
|
|
490
543
|
# The same two startup warnings the TypeScript, Python, and .NET SDKs
|
|
491
544
|
# log when no credentials are configured. Settled 2026-07-12: exact
|
|
492
|
-
# upstream branches only; credentials-plus-
|
|
545
|
+
# upstream branches only; credentials-plus-bypass stays silent like
|
|
493
546
|
# the other SDKs.
|
|
494
547
|
def warn_missing_credentials
|
|
495
548
|
return if @token_manager.client_id
|
|
496
549
|
|
|
497
|
-
if @
|
|
550
|
+
if @dangerously_allow_unauthenticated_requests
|
|
498
551
|
logger&.warn(
|
|
499
552
|
"No credentials configured (CLIENT_ID / CLIENT_SECRET / TENANT_ID), " \
|
|
500
|
-
"but
|
|
553
|
+
"but dangerously_allow_unauthenticated_requests is enabled. " \
|
|
554
|
+
"Bot will accept unauthenticated requests on #{@messaging_endpoint}."
|
|
501
555
|
)
|
|
502
556
|
else
|
|
503
557
|
logger&.warn(
|
|
504
|
-
"No credentials configured and
|
|
505
|
-
"Configure client authentication to securely receive
|
|
558
|
+
"No credentials configured and dangerously_allow_unauthenticated_requests is not enabled. " \
|
|
559
|
+
"All incoming requests will be rejected. Configure client authentication to securely receive " \
|
|
560
|
+
"messages, or set dangerously_allow_unauthenticated_requests: true for local development."
|
|
506
561
|
)
|
|
507
562
|
end
|
|
508
563
|
end
|
|
509
564
|
|
|
510
565
|
def validate_inbound!(env, activity)
|
|
511
|
-
return if @
|
|
566
|
+
return if @dangerously_allow_unauthenticated_requests
|
|
512
567
|
|
|
513
568
|
raise AuthenticationError, "CLIENT_ID is required for inbound validation" unless @jwt_validator
|
|
514
569
|
|
data/lib/teams/http_stream.rb
CHANGED
|
@@ -356,6 +356,9 @@ module Teams
|
|
|
356
356
|
body = activity.dup
|
|
357
357
|
body["from"] = conversation_reference.bot.to_h if conversation_reference.bot
|
|
358
358
|
body["conversation"] = conversation_reference.conversation.to_h
|
|
359
|
+
# Every streamed activity replies to the inbound message, restoring
|
|
360
|
+
# Bot Framework v1 threading behavior.
|
|
361
|
+
body["replyToId"] = conversation_reference.activity_id if conversation_reference.activity_id
|
|
359
362
|
|
|
360
363
|
# Stream chunks and the streamed final carry a streaminfo entity and are
|
|
361
364
|
# always created; only the timed-out in-place final routes through update.
|
data/lib/teams/router.rb
CHANGED
|
@@ -72,6 +72,12 @@ module Teams
|
|
|
72
72
|
register("message.submit", invoke_selector("message/submitAction"), &block)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
+
# application/search invokes - Adaptive Card dynamic typeahead
|
|
76
|
+
# Input.ChoiceSet queries (choices.data / Data.Query).
|
|
77
|
+
def on_card_search(&block)
|
|
78
|
+
register("card.search", invoke_selector("application/search"), &block)
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
# message/submitAction invokes whose actionName is "feedback" - the
|
|
76
82
|
# submissions from add_feedback's thumbs up/down UI.
|
|
77
83
|
def on_message_submit_feedback(&block)
|
|
@@ -90,6 +96,32 @@ module Teams
|
|
|
90
96
|
register("meeting_end", event_selector("application/vnd.microsoft.meetingEnd"), &block)
|
|
91
97
|
end
|
|
92
98
|
|
|
99
|
+
# conversationUpdate activities and their channel/team lifecycle
|
|
100
|
+
# sub-events, routed by channelData.eventType with the Python method
|
|
101
|
+
# names (the eventType literals are shared by all three SDKs).
|
|
102
|
+
def on_conversation_update(&block)
|
|
103
|
+
register("conversation_update", ->(activity) { activity.type == "conversationUpdate" }, &block)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
CONVERSATION_UPDATE_EVENTS = {
|
|
107
|
+
"on_channel_created" => "channelCreated",
|
|
108
|
+
"on_channel_deleted" => "channelDeleted",
|
|
109
|
+
"on_channel_renamed" => "channelRenamed",
|
|
110
|
+
"on_channel_restored" => "channelRestored",
|
|
111
|
+
"on_team_archived" => "teamArchived",
|
|
112
|
+
"on_team_deleted" => "teamDeleted",
|
|
113
|
+
"on_team_hard_deleted" => "teamHardDeleted",
|
|
114
|
+
"on_team_renamed" => "teamRenamed",
|
|
115
|
+
"on_team_restored" => "teamRestored",
|
|
116
|
+
"on_team_unarchived" => "teamUnarchived"
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
119
|
+
CONVERSATION_UPDATE_EVENTS.each do |method_name, event_type|
|
|
120
|
+
define_method(method_name) do |&block|
|
|
121
|
+
register(event_type, conversation_update_selector(event_type), &block)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
93
125
|
# Message extension (compose extension) invoke routes, using the same
|
|
94
126
|
# route names as the TypeScript and Python SDKs.
|
|
95
127
|
MESSAGE_EXTENSION_ROUTES = {
|
|
@@ -152,6 +184,12 @@ module Teams
|
|
|
152
184
|
->(activity) { activity.type == "event" && activity.name == event_name }
|
|
153
185
|
end
|
|
154
186
|
|
|
187
|
+
def conversation_update_selector(event_type)
|
|
188
|
+
lambda do |activity|
|
|
189
|
+
activity.type == "conversationUpdate" && activity.channel_data.event_type == event_type
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
155
193
|
def dialog_selector(invoke_name, key, expected)
|
|
156
194
|
lambda do |activity|
|
|
157
195
|
next false unless activity.invoke? && activity.name == invoke_name
|
data/lib/teams/version.rb
CHANGED
data/lib/teams.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "teams/api/account"
|
|
|
12
12
|
require_relative "teams/api/conversation_account"
|
|
13
13
|
require_relative "teams/api/conversation_resource"
|
|
14
14
|
require_relative "teams/api/paged_members_result"
|
|
15
|
+
require_relative "teams/api/app_info"
|
|
15
16
|
require_relative "teams/api/tenant_info"
|
|
16
17
|
require_relative "teams/api/team_info"
|
|
17
18
|
require_relative "teams/api/team_details"
|
|
@@ -27,6 +28,7 @@ require_relative "teams/api/sent_activity"
|
|
|
27
28
|
require_relative "teams/api/citation_appearance"
|
|
28
29
|
require_relative "teams/api/task_module"
|
|
29
30
|
require_relative "teams/api/message_extension"
|
|
31
|
+
require_relative "teams/api/search"
|
|
30
32
|
require_relative "teams/activity"
|
|
31
33
|
require_relative "teams/activity_context"
|
|
32
34
|
require_relative "teams/function_context"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: teams_rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Devran Cosmo Uenal
|
|
@@ -138,6 +138,7 @@ files:
|
|
|
138
138
|
- lib/teams/activity_context.rb
|
|
139
139
|
- lib/teams/api/account.rb
|
|
140
140
|
- lib/teams/api/activity_value.rb
|
|
141
|
+
- lib/teams/api/app_info.rb
|
|
141
142
|
- lib/teams/api/bot_sign_in_client.rb
|
|
142
143
|
- lib/teams/api/channel_data.rb
|
|
143
144
|
- lib/teams/api/channel_info.rb
|
|
@@ -158,6 +159,7 @@ files:
|
|
|
158
159
|
- lib/teams/api/paged_members_result.rb
|
|
159
160
|
- lib/teams/api/quoted_reply_entity.rb
|
|
160
161
|
- lib/teams/api/reaction_client.rb
|
|
162
|
+
- lib/teams/api/search.rb
|
|
161
163
|
- lib/teams/api/sent_activity.rb
|
|
162
164
|
- lib/teams/api/task_module.rb
|
|
163
165
|
- lib/teams/api/team_client.rb
|