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,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