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,62 @@
|
|
|
1
|
+
# Adaptive Cards
|
|
2
|
+
|
|
3
|
+
`Teams::Cards` provides typed builders for all 112 Adaptive Card element classes. They're generated from the Microsoft SDK's own card model, so they serialize to exactly the same JSON — including the host-specific defaults the Teams client expects.
|
|
4
|
+
|
|
5
|
+
## Building a card
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
C = Teams::Cards
|
|
9
|
+
|
|
10
|
+
card = C::AdaptiveCard.new(
|
|
11
|
+
C::TextBlock.new("Weekly report", size: "Large", weight: "Bolder"),
|
|
12
|
+
C::TextBlock.new("Everything is on track.", wrap: true),
|
|
13
|
+
C::FactSet.new(facts: [
|
|
14
|
+
C::Fact.new(title: "Status", value: "Green"),
|
|
15
|
+
C::Fact.new(title: "Owner", value: "Devran")
|
|
16
|
+
]),
|
|
17
|
+
actions: [
|
|
18
|
+
C::OpenUrlAction.new("https://example.com", title: "Details"),
|
|
19
|
+
C::SubmitAction.new(title: "Acknowledge", data: { "action" => "ack" })
|
|
20
|
+
]
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
ctx.post Teams::Api::MessageActivity.new.add_card(card)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Constructors take positional children where it reads naturally (a `TextBlock`'s text, a card's body elements) and keyword arguments for properties. Ruby conveniences like `add_item` / `add_action` / `add_choice` are available alongside the constructor forms.
|
|
27
|
+
|
|
28
|
+
## Sending cards
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
ctx.post card # card as the sole attachment
|
|
32
|
+
ctx.reply card
|
|
33
|
+
ctx.post Teams::Api::MessageActivity.new("See attached").add_card(card)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
A raw Hash works too, as an escape hatch — `add_card({ "type" => "AdaptiveCard", ... })`.
|
|
37
|
+
|
|
38
|
+
## Actions and submissions
|
|
39
|
+
|
|
40
|
+
`SubmitAction` data comes back as a message activity with no text and the data in `ctx.activity.value` — **answer it with `post`, never `reply`** (quoting the invisible submit activity is rejected by Teams):
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
teams.on_message do |ctx, nxt|
|
|
44
|
+
next nxt.call unless ctx.activity.text.nil? && ctx.activity.raw["value"]
|
|
45
|
+
ctx.post "Got: #{ctx.activity.raw["value"].inspect}"
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For richer action flows — opening a modal form from a card button — see [Dialogs](dialogs.md).
|
|
50
|
+
|
|
51
|
+
## Regenerating
|
|
52
|
+
|
|
53
|
+
The card classes are generated; don't hand-edit `lib/teams/cards/generated.rb`. To regenerate after an upstream card-model change:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
bundle exec rake cards:generate # reads a sibling teams.py checkout; TEAMS_PY_PATH overrides
|
|
57
|
+
bundle exec rake test # golden tests assert byte-identical serialization
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## A note on validation
|
|
61
|
+
|
|
62
|
+
Cards serialize faithfully but the SDK does not validate field values — invalid values pass straight through, exactly as raw hashes would. One live gotcha worth knowing: `CodeBlock`'s `language` is a server-side enum; unlisted values (including "Ruby") are rejected — use `"PlainText"`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Dialogs (task modules)
|
|
2
|
+
|
|
3
|
+
Dialogs are modal forms opened from a card action. A card button carrying `msteams: { type: "task/fetch" }` triggers a `task/fetch` invoke; the bot answers with the dialog content; the user's submission arrives as `task/submit`.
|
|
4
|
+
|
|
5
|
+
## Opening a dialog
|
|
6
|
+
|
|
7
|
+
The launcher card's action carries a reserved `dialog_id`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
teams.on_message(/^form$/i) do |ctx|
|
|
11
|
+
ctx.post Teams::Api::MessageActivity.new.add_card(
|
|
12
|
+
Teams::Cards::AdaptiveCard.new(
|
|
13
|
+
Teams::Cards::TextBlock.new("Open the form"),
|
|
14
|
+
actions: [Teams::Cards::SubmitAction.new(
|
|
15
|
+
title: "Open",
|
|
16
|
+
data: { "msteams" => { "type" => "task/fetch" }, "dialog_id" => "simple_form" }
|
|
17
|
+
)]
|
|
18
|
+
)
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Handle the open by returning the dialog:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
teams.on_dialog_open("simple_form") do |ctx|
|
|
27
|
+
Teams::Api::TaskModuleResponse.new(
|
|
28
|
+
Teams::Api::TaskModuleContinueResponse.new(
|
|
29
|
+
Teams::Api::TaskModuleTaskInfo.new(
|
|
30
|
+
title: "Simple form",
|
|
31
|
+
card: {
|
|
32
|
+
"type" => "AdaptiveCard", "version" => "1.4",
|
|
33
|
+
"body" => [{ "type" => "Input.Text", "id" => "name", "label" => "Name", "isRequired" => true }],
|
|
34
|
+
"actions" => [{ "type" => "Action.Submit", "title" => "Submit", "data" => { "action" => "submit_simple_form" } }]
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`on_dialog_open(dialog_id)` matches only that dialog; `on_dialog_open` with no argument matches all. `TaskModuleTaskInfo` accepts `card:` (an `AdaptiveCard`, card hash, or attachment) **or** `url:` for a webpage dialog, plus `title:`, `height:`/`width:` (`"small"`/`"medium"`/`"large"` or pixels).
|
|
43
|
+
|
|
44
|
+
## Handling submissions
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
teams.on_dialog_submit("submit_simple_form") do |ctx|
|
|
48
|
+
name = ctx.activity.value.data["name"]
|
|
49
|
+
ctx.post "Hi #{name}, thanks!"
|
|
50
|
+
Teams::Api::TaskModuleResponse.new(Teams::Api::TaskModuleMessageResponse.new("Submitted"))
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Return a `TaskModuleMessageResponse` to close the dialog with a message. `on_dialog_submit(action)` filters on the reserved `action` value in the submit data.
|
|
55
|
+
|
|
56
|
+
## Multi-step forms
|
|
57
|
+
|
|
58
|
+
Return another `TaskModuleContinueResponse` from a submit handler to advance to the next step — pass state forward in the next card's action data:
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
teams.on_dialog_submit("step_1") do |ctx|
|
|
62
|
+
name = ctx.activity.value.data["name"]
|
|
63
|
+
Teams::Api::TaskModuleResponse.new(
|
|
64
|
+
Teams::Api::TaskModuleContinueResponse.new(
|
|
65
|
+
Teams::Api::TaskModuleTaskInfo.new(
|
|
66
|
+
title: "Step 2",
|
|
67
|
+
card: {
|
|
68
|
+
"type" => "AdaptiveCard", "version" => "1.4",
|
|
69
|
+
"body" => [{ "type" => "Input.Text", "id" => "email", "label" => "Email" }],
|
|
70
|
+
"actions" => [{ "type" => "Action.Submit", "title" => "Finish",
|
|
71
|
+
"data" => { "action" => "step_2", "name" => name } }]
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Feedback
|
|
2
|
+
|
|
3
|
+
Add thumbs up/down feedback buttons to a bot message — the standard way to collect quality signals on AI answers.
|
|
4
|
+
|
|
5
|
+
## Requesting feedback
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
ctx.post Teams::Api::MessageActivity.new("Here's my answer.")
|
|
9
|
+
.add_ai_generated
|
|
10
|
+
.add_feedback
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`add_feedback` enables the default feedback UI on the message. Teams renders the thumbs on hover; when a user submits feedback, your bot receives a `message/submitAction` invoke.
|
|
14
|
+
|
|
15
|
+
## Handling submissions
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
teams.on_message_submit_feedback do |ctx|
|
|
19
|
+
value = ctx.activity.value.raw
|
|
20
|
+
FeedbackRecord.create!(
|
|
21
|
+
reply_to: ctx.activity.raw["replyToId"], # the message being rated
|
|
22
|
+
reaction: value.dig("actionValue", "reaction"), # "like" / "dislike"
|
|
23
|
+
text: value.dig("actionValue", "feedback") # free-text, JSON-encoded by some clients
|
|
24
|
+
)
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The rated message's id arrives as the invoke's `replyToId` — store your messages' `SentActivity#id`s if you need to join feedback back to content. `on_message_submit` (without the filter) catches every `message/submitAction` invoke, matching the TypeScript/Python route pair.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Meeting events
|
|
2
|
+
|
|
3
|
+
When a bot is installed in a meeting chat, Teams posts event activities as the meeting starts and ends.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
teams.on_meeting_start do |ctx|
|
|
7
|
+
v = ctx.activity.value
|
|
8
|
+
ctx.post "#{v.title} started at #{v.start_time}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
teams.on_meeting_end do |ctx|
|
|
12
|
+
ctx.post "Meeting ended at #{ctx.activity.value.end_time}"
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The event value is available on `ctx.activity.value`, with readers matching the wire fields (which Teams sends PascalCase):
|
|
17
|
+
|
|
18
|
+
| Reader | Field | Notes |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `id` | `Id` | Base64-encoded meeting id |
|
|
21
|
+
| `title` | `Title` | Meeting title |
|
|
22
|
+
| `meeting_type` | `MeetingType` | e.g. `"Scheduled"` |
|
|
23
|
+
| `join_url` | `JoinUrl` | Join link (start event) |
|
|
24
|
+
| `start_time` | `StartTime` | UTC timestamp (start event) |
|
|
25
|
+
| `end_time` | `EndTime` | UTC timestamp (end event) |
|
|
26
|
+
|
|
27
|
+
For meeting details and participant lookup outside events, use the [meetings API client](../essentials/api-client.md#teams-and-meetings).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Message extensions
|
|
2
|
+
|
|
3
|
+
Message extensions (compose extensions) add search boxes, action commands, and link unfurling to the Teams compose area. The commands are declared in the app manifest; the SDK routes the `composeExtension/*` invokes to named handlers whose return values become the invoke response.
|
|
4
|
+
|
|
5
|
+
> The manifest commands must be added in the classic Developer Portal view or the app package editor — the new Developer Portal UI currently lacks the message-extension commands editor.
|
|
6
|
+
|
|
7
|
+
## Search commands
|
|
8
|
+
|
|
9
|
+
A search box in the compose area. Return a `MessagingExtensionResponse` wrapping a result list; each attachment can carry a preview card:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
teams.on_message_ext_query do |ctx|
|
|
13
|
+
query = ctx.activity.value.parameters.find { |p| p["name"] == "searchQuery" }&.dig("value")
|
|
14
|
+
attachments = Item.search(query).map do |item|
|
|
15
|
+
Teams::Api::MessagingExtensionAttachment.new(
|
|
16
|
+
content_type: "application/vnd.microsoft.card.adaptive",
|
|
17
|
+
content: item.to_card,
|
|
18
|
+
preview: { "contentType" => "application/vnd.microsoft.card.thumbnail",
|
|
19
|
+
"content" => { "title" => item.title } }
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Teams::Api::MessagingExtensionResponse.new(
|
|
24
|
+
Teams::Api::MessagingExtensionResult.new(type: "result", attachment_layout: "list", attachments: attachments)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`on_message_ext_select_item` handles clicking a result when you return lightweight items and expand on selection.
|
|
30
|
+
|
|
31
|
+
## Action commands
|
|
32
|
+
|
|
33
|
+
An action command opens a dialog (via `fetchTask`) and then handles the submission — returning a `MessagingExtensionActionResponse`, which reuses the [dialog](dialogs.md) task-module responses:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
teams.on_message_ext_open do |ctx|
|
|
37
|
+
Teams::Api::MessagingExtensionActionResponse.new(
|
|
38
|
+
task: Teams::Api::TaskModuleContinueResponse.new(
|
|
39
|
+
Teams::Api::TaskModuleTaskInfo.new(title: "Create", card: create_form_card)
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
teams.on_message_ext_submit do |ctx|
|
|
45
|
+
item = Item.create!(title: ctx.activity.value.data["title"])
|
|
46
|
+
Teams::Api::MessagingExtensionActionResponse.new(
|
|
47
|
+
compose_extension: Teams::Api::MessagingExtensionResult.new(
|
|
48
|
+
type: "result", attachment_layout: "list",
|
|
49
|
+
attachments: [Teams::Api::MessagingExtensionAttachment.new(
|
|
50
|
+
content_type: "application/vnd.microsoft.card.adaptive", content: item.to_card
|
|
51
|
+
)]
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Link unfurling
|
|
58
|
+
|
|
59
|
+
When a user pastes a URL from a domain your manifest registers, Teams sends a `composeExtension/queryLink` invoke — return a card preview for it:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
teams.on_message_ext_query_link do |ctx|
|
|
63
|
+
url = ctx.activity.value.raw["url"]
|
|
64
|
+
Teams::Api::MessagingExtensionResponse.new(
|
|
65
|
+
Teams::Api::MessagingExtensionResult.new(
|
|
66
|
+
type: "result", attachment_layout: "list",
|
|
67
|
+
attachments: [Teams::Api::MessagingExtensionAttachment.new(
|
|
68
|
+
content_type: "application/vnd.microsoft.card.adaptive", content: unfurl_card(url)
|
|
69
|
+
)]
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## All routes
|
|
76
|
+
|
|
77
|
+
`on_message_ext_query`, `on_message_ext_select_item`, `on_message_ext_submit`, `on_message_ext_open`, `on_message_ext_query_link`, `on_message_ext_anon_query_link`, `on_message_ext_query_settings_url`, `on_message_ext_setting`, `on_message_ext_card_button_clicked` — the same nine the TypeScript and Python SDKs expose. The inbound command and parameters read from `ctx.activity.value` (`command_id`, `parameters`, `data`).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Message reactions
|
|
2
|
+
|
|
3
|
+
## Reacting to messages
|
|
4
|
+
|
|
5
|
+
The bot can add and remove reactions on any message via the API client:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
teams.on_message do |ctx|
|
|
9
|
+
ctx.api.conversations.add_reaction(ctx.ref.conversation_id, ctx.activity.id, "like")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
api.conversations.delete_reaction(conversation_id, activity_id, "like")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Reaction types: `like`, `heart`, `laugh`, `surprised`, `sad`, `angry`.
|
|
16
|
+
|
|
17
|
+
## Receiving reactions
|
|
18
|
+
|
|
19
|
+
When a user reacts to one of the bot's messages, Teams sends a `messageReaction` activity. Route it with the activity-type escape hatch:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
teams.on("messageReaction") do |ctx|
|
|
23
|
+
added = Array(ctx.activity.raw["reactionsAdded"]).map { |r| r["type"] }
|
|
24
|
+
removed = Array(ctx.activity.raw["reactionsRemoved"]).map { |r| r["type"] }
|
|
25
|
+
ctx.log.info("reactions +#{added.inspect} -#{removed.inspect} on #{ctx.activity.raw["replyToId"]}")
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The reacted-to message's id arrives as `replyToId`. Reaction activities carry no `text` and need no response — return nothing.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
## Logging
|
|
4
|
+
|
|
5
|
+
The app logs to the `logger:` you pass (stdout by default). At debug level it logs every inbound activity (type, invoke name, id, matched route count) and every API request; at warn level it logs rejected requests (invalid JSON, auth failures) and OAuth/streaming issues; handler errors log at error level before the 500 response.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
teams = Teams::App.new(logger: Rails.logger)
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Inside handlers, `ctx.log` is that same logger.
|
|
12
|
+
|
|
13
|
+
## Middleware
|
|
14
|
+
|
|
15
|
+
`use` wraps every activity — the place for timing, request ids, structured context, or short-circuiting. The second block parameter continues the chain:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
teams.use do |ctx, nxt|
|
|
19
|
+
ctx.log.info("→ #{ctx.activity.type} from #{ctx.activity.from&.id}")
|
|
20
|
+
nxt.call
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Middleware runs before route handlers; omit `nxt.call` to stop processing (e.g. to drop an activity). See [Listening to activities](../essentials/on-activity.md#middleware) for the ordering rules.
|
|
25
|
+
|
|
26
|
+
## Health
|
|
27
|
+
|
|
28
|
+
The messaging endpoint returns `401` to unauthenticated requests once credentials are configured — a simple external signal that the app is up and validating. A `POST /api/messages` with an empty body is the cheapest probe.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Streaming
|
|
2
|
+
|
|
3
|
+
Stream a response in chunks — the "typing then progressively filling in" experience used for AI answers. `ctx.stream` handles the Teams streaming protocol; you just emit.
|
|
4
|
+
|
|
5
|
+
## Emitting
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
teams.on_message do |ctx|
|
|
9
|
+
ctx.stream.update "Thinking..." # informative status line (visible above the response)
|
|
10
|
+
answer_tokens.each { |t| ctx.stream.emit(t) }
|
|
11
|
+
# the stream finalizes automatically when the handler returns
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
- `emit(text)` appends to the streamed message.
|
|
16
|
+
- `update(text)` sends an informative status update (shown before content starts).
|
|
17
|
+
- `clear_text` discards accumulated text — e.g. to replace streamed text with a final card.
|
|
18
|
+
- `close` finalizes explicitly; it's called for you when the handler returns.
|
|
19
|
+
|
|
20
|
+
Emits are queued and flushed by a background thread, matching the TypeScript and Python streamers: rapid emits coalesce into fewer chunks (spaced to respect Teams rate limits), and `close` waits for the queue to drain before sending the final message.
|
|
21
|
+
|
|
22
|
+
## Final message metadata
|
|
23
|
+
|
|
24
|
+
The final streamed message can carry the same enrichment as a normal message:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
ctx.stream.emit "Here's the summary. "
|
|
28
|
+
ctx.stream.emit Teams::Api::MessageActivity.new.add_ai_generated
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Events
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
ctx.stream.on_chunk { |sent| ctx.log.debug("chunk #{sent.id}") }
|
|
35
|
+
ctx.stream.on_close { |sent| Analytics.record_stream(sent.id) }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`on_chunk` fires with the `SentActivity` of each shipped chunk; `on_close` fires once with the final `SentActivity`. Handlers persist across stream reuse.
|
|
39
|
+
|
|
40
|
+
## Reuse and typed errors
|
|
41
|
+
|
|
42
|
+
Emitting again after `close` starts a **new** streamed message on the same stream. If Teams stops a stream, the SDK raises typed errors:
|
|
43
|
+
|
|
44
|
+
- `Teams::StreamCancelledError` — the user cancelled (sticky: the next `emit` also raises)
|
|
45
|
+
- `Teams::StreamNotAllowedError`, `Teams::TerminalStreamError` — terminal failures
|
|
46
|
+
|
|
47
|
+
Chunk-send errors are recorded on the stream and surface when `close` sends the final message. A stream that exceeds the Teams two-minute limit finalizes automatically by updating the streamed message in place.
|
|
48
|
+
|
|
49
|
+
## Gotchas
|
|
50
|
+
|
|
51
|
+
- The Teams client renders **only one** streamed message per inbound turn well; use reuse deliberately.
|
|
52
|
+
- A **card-only** stream (no text ever emitted) sends nothing, matching the other SDKs — emit text chunks first, then `clear_text` and emit the card as the final message.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Tabs and remote functions
|
|
2
|
+
|
|
3
|
+
A tab is a web page hosted inside Teams. Serve the page from your own app (Rails route, `public/`, any host) — the SDK doesn't host pages, since your framework already does that better. What the SDK provides is **remote functions**: authenticated endpoints your tab's JavaScript calls to reach the bot backend as the signed-in user.
|
|
4
|
+
|
|
5
|
+
## Registering a function
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
teams.on_function("create-ticket") do |ctx|
|
|
9
|
+
ticket = Ticket.create!(title: ctx.data["title"], creator_oid: ctx.user_id)
|
|
10
|
+
ctx.post "#{ctx.user_name} created ticket ##{ticket.id} from the tab"
|
|
11
|
+
{ "id" => ticket.id }
|
|
12
|
+
end
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This serves `POST /api/functions/create-ticket`. The handler receives a `FunctionContext`:
|
|
16
|
+
|
|
17
|
+
- `ctx.data` — the parsed JSON request body
|
|
18
|
+
- `ctx.user_id`, `ctx.tenant_id`, `ctx.user_name` — from the validated Entra token
|
|
19
|
+
- `ctx.chat_id`, `ctx.channel_id`, `ctx.meeting_id`, `ctx.team_id`, `ctx.page_id`, `ctx.app_session_id` — from the `X-Teams-*` client-context headers
|
|
20
|
+
- `ctx.conversation_id` — resolves the conversation (validating membership; creating the 1:1 in personal scope)
|
|
21
|
+
- `ctx.post` — sends into the resolved conversation proactively
|
|
22
|
+
|
|
23
|
+
The handler's return value becomes the JSON response body.
|
|
24
|
+
|
|
25
|
+
## Validation
|
|
26
|
+
|
|
27
|
+
Every call is validated like the TypeScript/Python SDKs: required `X-Teams-App-Session-Id` / `X-Teams-Page-Id` / bearer headers, the Entra token verified against your app registration (client id audience forms, tenant issuer), and the `oid`/`tid`/`name` claims. Invalid requests get a `401` with a `detail` message; unregistered names get a `404`.
|
|
28
|
+
|
|
29
|
+
## Calling from the tab
|
|
30
|
+
|
|
31
|
+
The tab page acquires a token through the Teams JS SDK and posts it with the client-context headers:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const token = await microsoftTeams.authentication.getAuthToken();
|
|
35
|
+
const context = await microsoftTeams.app.getContext();
|
|
36
|
+
|
|
37
|
+
await fetch("/api/functions/create-ticket", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: {
|
|
40
|
+
"Content-Type": "application/json",
|
|
41
|
+
"Authorization": "Bearer " + token,
|
|
42
|
+
"X-Teams-App-Session-Id": context.app.sessionId,
|
|
43
|
+
"X-Teams-Page-Id": context.page.id,
|
|
44
|
+
...(context.chat?.id ? { "X-Teams-Chat-Id": context.chat.id } : {})
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({ title: "New ticket" })
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## SSO setup
|
|
51
|
+
|
|
52
|
+
Tab SSO needs an **Application ID URI whose domain matches the tab's origin**, with `requestedAccessTokenVersion: 2` on the app registration:
|
|
53
|
+
|
|
54
|
+
- Application ID URI: `api://<your-tab-domain>/<app-id>` (a distinct path suffix like `/<app-id>/tab` avoids collisions if the base URI is taken)
|
|
55
|
+
- Pre-authorize the two Teams client app ids (`1fec8e78-bce4-4aaf-ab1b-5451cc387264`, `5e3ce6c0-2b1f-4285-8d4b-75ee78787346`) on the exposed `access_as_user` scope
|
|
56
|
+
- Manifest `webApplicationInfo.resource` must match the Application ID URI exactly
|
|
57
|
+
- Manifest `staticTabs` entry points `contentUrl` at your page
|
|
58
|
+
|
|
59
|
+
> Watch for **duplicate app registrations** — the Developer Portal can create a second one; make sure the SSO configuration lands on the registration whose client id your bot uses.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# User authentication
|
|
2
|
+
|
|
3
|
+
Sign a user in and act as them — call Microsoft Graph, your own API, anything needing their delegated token. Built on an OAuth connection configured on the bot's Azure registration.
|
|
4
|
+
|
|
5
|
+
## Azure setup
|
|
6
|
+
|
|
7
|
+
User auth needs an **OAuth connection setting**, which lives only on an **Azure Bot resource** — a Developer Portal bot registration has no place for it. If your bot was created in the Developer Portal, migrate it: delete the Developer Portal bot registration, create an Azure Bot resource with **"Use existing app registration"** and the same app id, then re-set the messaging endpoint and re-enable the Teams channel. The app id never changes, so Teams sees no difference.
|
|
8
|
+
|
|
9
|
+
Then on the Azure Bot's Configuration blade → **Add OAuth Connection Settings**:
|
|
10
|
+
|
|
11
|
+
- **Name** — match `default_connection_name` (default `"graph"`)
|
|
12
|
+
- **Service Provider** — Azure Active Directory v2
|
|
13
|
+
- **Client id / secret** — reuse the bot's own credentials
|
|
14
|
+
- **Tenant ID** — your tenant
|
|
15
|
+
- **Scopes** — e.g. `openid profile User.Read`
|
|
16
|
+
|
|
17
|
+
And in the Entra app registration: add the Web redirect URI `https://token.botframework.com/.auth/web/redirect`, plus the delegated permissions your scopes name. Use **Test Connection** on the saved setting to confirm before touching code.
|
|
18
|
+
|
|
19
|
+
## The flow
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
teams = Teams::App.new(default_connection_name: "graph")
|
|
23
|
+
|
|
24
|
+
teams.on_message(/^login$/i) do |ctx|
|
|
25
|
+
token = ctx.sign_in
|
|
26
|
+
ctx.reply "Already signed in!" if token # nil means a card was sent
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
teams.on_sign_in do |ctx, token_response|
|
|
30
|
+
ctx.post "Welcome! You're signed in."
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`ctx.sign_in` returns the token when the user is already signed in; otherwise it sends an OAuth card (to a 1:1 conversation when invoked from a group chat) and returns `nil`. 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
|
+
|
|
36
|
+
`ctx.sign_out` clears the stored token.
|
|
37
|
+
|
|
38
|
+
## Using the token
|
|
39
|
+
|
|
40
|
+
Most often through the [user Graph client](../essentials/graph.md):
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
teams.on_sign_in do |ctx, _token|
|
|
44
|
+
me = ctx.user_graph.get("/me")
|
|
45
|
+
ctx.post "Hello #{me["displayName"]}"
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or the raw token from the token service:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
response = ctx.api.users.get_token(
|
|
53
|
+
user_id: ctx.activity.from.id, connection_name: "graph", channel_id: ctx.activity.channel_id
|
|
54
|
+
)
|
|
55
|
+
response.token # the delegated access token
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Custom invoke handling
|
|
59
|
+
|
|
60
|
+
The defaults run first; handlers you register on `on_signin_verify_state` / `on_signin_token_exchange` / `on_signin_failure` run **after** them, for custom behavior. Don't fetch the token yourself in a verify-state handler — the magic code is single-use and the default already consumed it.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teams
|
|
4
|
+
class Activity
|
|
5
|
+
attr_reader :raw
|
|
6
|
+
|
|
7
|
+
def initialize(raw = {})
|
|
8
|
+
@raw = normalize_hash(raw)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def type
|
|
12
|
+
raw["type"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
raw["name"]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def text
|
|
20
|
+
raw["text"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def id
|
|
24
|
+
raw["id"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reply_to_id
|
|
28
|
+
raw["replyToId"] || raw["reply_to_id"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def service_url
|
|
32
|
+
raw["serviceUrl"] || raw["service_url"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def channel_id
|
|
36
|
+
raw["channelId"] || raw["channel_id"]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def locale
|
|
40
|
+
raw["locale"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def local_timestamp
|
|
44
|
+
raw["localTimestamp"] || raw["local_timestamp"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def channel_data
|
|
48
|
+
Api::ChannelData.new(raw["channelData"] || raw["channel_data"] || {})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def from
|
|
52
|
+
Api::Account.new(raw["from"] || {})
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def recipient
|
|
56
|
+
Api::Account.new(raw["recipient"] || {})
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def conversation
|
|
60
|
+
Api::ConversationAccount.new(raw["conversation"] || {})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def value
|
|
64
|
+
value = raw["value"]
|
|
65
|
+
value.is_a?(Hash) ? Api::ActivityValue.new(value) : value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def entities
|
|
69
|
+
Array(raw["entities"])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def get_quoted_messages
|
|
73
|
+
entities
|
|
74
|
+
.select { |entity| entity.is_a?(Hash) && entity["type"] == "quotedReply" }
|
|
75
|
+
.map { |entity| Api::QuotedReplyEntity.new(entity) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def get_mentions
|
|
79
|
+
entities.select { |entity| entity.is_a?(Hash) && entity["type"] == "mention" }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def get_account_mention(account_id)
|
|
83
|
+
get_mentions.find { |mention| mention.dig("mentioned", "id") == account_id }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def recipient_mentioned?
|
|
87
|
+
!get_account_mention(recipient.id).nil?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the text with "<at>...</at>" mention tokens removed, without
|
|
91
|
+
# mutating the raw activity. With tag_only, only the tags are removed and
|
|
92
|
+
# the mention names are kept.
|
|
93
|
+
def strip_mentions_text(account_id: nil, tag_only: false)
|
|
94
|
+
return nil unless text
|
|
95
|
+
|
|
96
|
+
get_mentions.reduce(text) do |result, mention|
|
|
97
|
+
next result if account_id && mention.dig("mentioned", "id") != account_id
|
|
98
|
+
|
|
99
|
+
mention_text = mention["text"].to_s
|
|
100
|
+
if !mention_text.empty?
|
|
101
|
+
without_tags = mention_text.gsub("<at>", "").gsub("</at>", "")
|
|
102
|
+
result.sub(mention_text, tag_only ? without_tags : "")
|
|
103
|
+
elsif (name = mention.dig("mentioned", "name"))
|
|
104
|
+
result.sub("<at>#{name}</at>", tag_only ? name : "")
|
|
105
|
+
else
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
end.strip
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def to_h
|
|
112
|
+
raw.dup
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def message?
|
|
116
|
+
type == "message"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def message_update?
|
|
120
|
+
type == "messageUpdate"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def typing?
|
|
124
|
+
type == "typing"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def invoke?
|
|
128
|
+
type == "invoke"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def install_update?
|
|
132
|
+
type == "installationUpdate"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def suggested_action_submit?
|
|
136
|
+
type == "invoke" && name == "suggestedActions/submit"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
private
|
|
140
|
+
|
|
141
|
+
def normalize_hash(value)
|
|
142
|
+
return {} if value.nil?
|
|
143
|
+
|
|
144
|
+
value.each_with_object({}) do |(key, item), result|
|
|
145
|
+
result[key.to_s] = normalize_value(item)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def normalize_value(value)
|
|
150
|
+
case value
|
|
151
|
+
when Hash
|
|
152
|
+
normalize_hash(value)
|
|
153
|
+
when Array
|
|
154
|
+
value.map { |item| normalize_value(item) }
|
|
155
|
+
else
|
|
156
|
+
value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|