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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ class ConversationAccount < Model
6
+ def id
7
+ read("id")
8
+ end
9
+
10
+ def tenant_id
11
+ read("tenantId", "tenant_id")
12
+ end
13
+
14
+ def conversation_type
15
+ read("conversationType", "conversation_type")
16
+ end
17
+
18
+ def name
19
+ read("name")
20
+ end
21
+
22
+ def is_group
23
+ read("isGroup", "is_group")
24
+ end
25
+
26
+ def to_h
27
+ body = raw.dup
28
+ body["id"] = id if id
29
+ body["tenantId"] = tenant_id if tenant_id
30
+ body["conversationType"] = conversation_type if conversation_type
31
+ body["name"] = name if name
32
+ body["isGroup"] = is_group unless is_group.nil?
33
+ body.delete("tenant_id")
34
+ body.delete("conversation_type")
35
+ body.delete("is_group")
36
+ body
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Teams
6
+ module Api
7
+ class ConversationClient
8
+ TARGETED_PARAMS = { "isTargetedActivity" => "true" }.freeze
9
+
10
+ attr_reader :service_url, :http
11
+
12
+ def initialize(service_url:, http:, logger: nil)
13
+ @service_url = service_url.sub(%r{/+\z}, "")
14
+ @http = http
15
+ @logger = logger
16
+ @reactions = ReactionClient.new(service_url: @service_url, http:)
17
+ end
18
+
19
+ # Creates a conversation (or returns the pre-existing one for the same
20
+ # members). isGroup/bot/topicName are omitted: the SDKs deprecate them
21
+ # for removal, and Python never had them.
22
+ def create(members: nil, tenant_id: nil, activity: nil, channel_data: nil, service_url: nil)
23
+ body = {}
24
+ body["members"] = members.map { |member| account_to_h(member) } if members
25
+ body["tenantId"] = tenant_id if tenant_id
26
+ body["activity"] = activity_to_h(activity) if activity
27
+ body["channelData"] = Common::Hashes.deep_stringify_keys(channel_data) if channel_data
28
+
29
+ url = absolute("/v3/conversations", service_url:)
30
+ @logger&.debug("Teams API POST #{url}")
31
+ ConversationResource.new(http.post(url, json: body))
32
+ end
33
+
34
+ def create_activity(conversation_id, activity, service_url: nil)
35
+ path = "/v3/conversations/#{escape(conversation_id)}/activities"
36
+ url = absolute(path, service_url:)
37
+ @logger&.debug("Teams API POST #{url}")
38
+ http.post(url, json: activity_to_h(activity))
39
+ end
40
+
41
+ def reply_to_activity(conversation_id, activity_id, activity, service_url: nil)
42
+ body = activity_to_h(activity)
43
+ body = body.merge("replyToId" => activity_id) if body.is_a?(Hash)
44
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
45
+ url = absolute(path, service_url:)
46
+ @logger&.debug("Teams API POST #{url}")
47
+ http.post(url, json: body)
48
+ end
49
+
50
+ def update_activity(conversation_id, activity_id, activity, service_url: nil)
51
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
52
+ url = absolute(path, service_url:)
53
+ @logger&.debug("Teams API PUT #{url}")
54
+ http.put(url, json: activity_to_h(activity))
55
+ end
56
+
57
+ def delete_activity(conversation_id, activity_id, service_url: nil)
58
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
59
+ url = absolute(path, service_url:)
60
+ @logger&.debug("Teams API DELETE #{url}")
61
+ http.delete(url)
62
+ end
63
+
64
+ def create_targeted_activity(conversation_id, activity, service_url: nil)
65
+ path = "/v3/conversations/#{escape(conversation_id)}/activities"
66
+ url = absolute(path, service_url:)
67
+ @logger&.debug("Teams API POST #{url} (targeted)")
68
+ http.post(url, json: activity_to_h(activity), params: TARGETED_PARAMS)
69
+ end
70
+
71
+ def update_targeted_activity(conversation_id, activity_id, activity, service_url: nil)
72
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
73
+ url = absolute(path, service_url:)
74
+ @logger&.debug("Teams API PUT #{url} (targeted)")
75
+ http.put(url, json: activity_to_h(activity), params: TARGETED_PARAMS)
76
+ end
77
+
78
+ def delete_targeted_activity(conversation_id, activity_id, service_url: nil)
79
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
80
+ url = absolute(path, service_url:)
81
+ @logger&.debug("Teams API DELETE #{url} (targeted)")
82
+ http.delete(url, params: TARGETED_PARAMS)
83
+ end
84
+
85
+ # The backend returns objectId instead of aadObjectId on some member
86
+ # endpoints; Api::Account reads both, so the accounts here are already
87
+ # normalized like the other SDKs' TeamsChannelAccount.
88
+ def get_members(conversation_id, service_url: nil)
89
+ path = "/v3/conversations/#{escape(conversation_id)}/members"
90
+ url = absolute(path, service_url:)
91
+ @logger&.debug("Teams API GET #{url}")
92
+ Array(http.get(url)).map { |member| Account.new(member) }
93
+ end
94
+
95
+ def get_member_by_id(conversation_id, member_id, service_url: nil)
96
+ path = "/v3/conversations/#{escape(conversation_id)}/members/#{escape(member_id)}"
97
+ url = absolute(path, service_url:)
98
+ @logger&.debug("Teams API GET #{url}")
99
+ Account.new(http.get(url))
100
+ end
101
+
102
+ def get_paged_members(conversation_id, page_size: nil, continuation_token: nil, service_url: nil)
103
+ params = {}
104
+ params["pageSize"] = page_size if page_size
105
+ params["continuationToken"] = continuation_token if continuation_token
106
+
107
+ path = "/v3/conversations/#{escape(conversation_id)}/pagedMembers"
108
+ url = absolute(path, service_url:)
109
+ @logger&.debug("Teams API GET #{url}")
110
+ PagedMembersResult.new(http.get(url, params: params.empty? ? nil : params))
111
+ end
112
+
113
+ def get_activity_members(conversation_id, activity_id, service_url: nil)
114
+ path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}/members"
115
+ url = absolute(path, service_url:)
116
+ @logger&.debug("Teams API GET #{url}")
117
+ Array(http.get(url)).map { |member| Account.new(member) }
118
+ end
119
+
120
+ def add_reaction(conversation_id, activity_id, reaction_type)
121
+ reactions.add(conversation_id, activity_id, reaction_type)
122
+ end
123
+
124
+ def delete_reaction(conversation_id, activity_id, reaction_type)
125
+ reactions.delete(conversation_id, activity_id, reaction_type)
126
+ end
127
+
128
+ private
129
+
130
+ attr_reader :reactions
131
+
132
+ def account_to_h(account)
133
+ account.is_a?(Hash) ? Common::Hashes.deep_stringify_keys(account) : account.to_h
134
+ end
135
+
136
+ def activity_to_h(activity)
137
+ case activity
138
+ when String
139
+ MessageActivity.new(activity).to_h
140
+ when Hash
141
+ Common::Hashes.deep_stringify_keys(activity)
142
+ else
143
+ activity.respond_to?(:to_h) ? activity.to_h : activity
144
+ end
145
+ end
146
+
147
+ def absolute(path, service_url: nil)
148
+ "#{(service_url || self.service_url).sub(%r{/+\z}, "")}#{path}"
149
+ end
150
+
151
+ def escape(value)
152
+ URI.encode_www_form_component(value.to_s)
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Teams
6
+ module Api
7
+ class ConversationReference
8
+ attr_reader :activity_id, :user, :locale, :bot, :conversation, :channel_id, :service_url
9
+
10
+ def self.from_activity(activity)
11
+ new(
12
+ activity_id: activity.id,
13
+ user: activity.from,
14
+ locale: activity.locale,
15
+ bot: activity.recipient,
16
+ conversation: activity.conversation,
17
+ channel_id: activity.channel_id,
18
+ service_url: activity.service_url
19
+ )
20
+ end
21
+
22
+ def self.from_h(raw)
23
+ raw = raw.to_h if raw.respond_to?(:to_h)
24
+
25
+ new(
26
+ activity_id: raw["activityId"] || raw["activity_id"] || raw[:activityId] || raw[:activity_id],
27
+ user: raw["user"] || raw[:user],
28
+ locale: raw["locale"] || raw[:locale],
29
+ bot: raw.fetch("bot") { raw.fetch(:bot) },
30
+ conversation: raw.fetch("conversation") { raw.fetch(:conversation) },
31
+ channel_id: raw["channelId"] || raw["channel_id"] || raw[:channelId] || raw[:channel_id],
32
+ service_url: raw["serviceUrl"] || raw["service_url"] || raw[:serviceUrl] || raw[:service_url]
33
+ )
34
+ end
35
+
36
+ def initialize(activity_id: nil, user: nil, locale: nil, bot:, conversation:, channel_id:, service_url:)
37
+ @activity_id = activity_id
38
+ @user = wrap_account(user)
39
+ @locale = locale
40
+ @bot = wrap_account(bot)
41
+ @conversation = wrap_conversation(conversation)
42
+ @channel_id = channel_id
43
+ @service_url = service_url
44
+ end
45
+
46
+ def conversation_id
47
+ conversation.id
48
+ end
49
+
50
+ def to_h
51
+ body = {
52
+ "bot" => bot.to_h,
53
+ "conversation" => conversation.to_h,
54
+ "channelId" => channel_id,
55
+ "serviceUrl" => service_url
56
+ }
57
+ body["activityId"] = activity_id if activity_id
58
+ body["user"] = user.to_h if user
59
+ body["locale"] = locale if locale
60
+ body
61
+ end
62
+
63
+ def to_json(*args)
64
+ JSON.generate(to_h, *args)
65
+ end
66
+
67
+ private
68
+
69
+ def wrap_account(value)
70
+ return value if value.is_a?(Account)
71
+ return nil if value.nil?
72
+
73
+ Account.new(value)
74
+ end
75
+
76
+ def wrap_conversation(value)
77
+ return value if value.is_a?(ConversationAccount)
78
+
79
+ ConversationAccount.new(value)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ class ConversationResource < Model
6
+ def id
7
+ read("id")
8
+ end
9
+
10
+ def activity_id
11
+ read("activityId", "activity_id")
12
+ end
13
+
14
+ def service_url
15
+ read("serviceUrl", "service_url")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Teams
6
+ module Api
7
+ class MeetingClient
8
+ attr_reader :service_url, :http
9
+
10
+ def initialize(service_url:, http:, logger: nil)
11
+ @service_url = service_url.sub(%r{/+\z}, "")
12
+ @http = http
13
+ @logger = logger
14
+ end
15
+
16
+ def get_by_id(meeting_id, service_url: nil)
17
+ path = "/v1/meetings/#{escape(meeting_id)}"
18
+ url = absolute(path, service_url:)
19
+ @logger&.debug("Teams API GET #{url}")
20
+ MeetingInfo.new(http.get(url))
21
+ end
22
+
23
+ # participant_id is the user's AAD object id; tenant_id is required by
24
+ # the service.
25
+ def get_participant(meeting_id, participant_id, tenant_id, service_url: nil)
26
+ path = "/v1/meetings/#{escape(meeting_id)}/participants/#{escape(participant_id)}"
27
+ url = absolute(path, service_url:)
28
+ @logger&.debug("Teams API GET #{url}")
29
+ MeetingParticipant.new(http.get(url, params: { "tenantId" => tenant_id }))
30
+ end
31
+
32
+ # Sends a targeted in-meeting notification. Returns nil when every
33
+ # recipient succeeded (202); returns a MeetingNotificationResponse with
34
+ # per-recipient failures on partial success (207).
35
+ def send_notification(meeting_id, params, service_url: nil)
36
+ body = Common::Hashes.deep_stringify_keys(params.respond_to?(:to_h) ? params.to_h : params)
37
+ body["type"] ||= "targetedMeetingNotification"
38
+
39
+ path = "/v1/meetings/#{escape(meeting_id)}/notification"
40
+ url = absolute(path, service_url:)
41
+ @logger&.debug("Teams API POST #{url}")
42
+ response = http.post(url, json: body)
43
+ response ? MeetingNotificationResponse.new(response) : nil
44
+ end
45
+
46
+ private
47
+
48
+ def absolute(path, service_url: nil)
49
+ "#{(service_url || self.service_url).sub(%r{/+\z}, "")}#{path}"
50
+ end
51
+
52
+ def escape(value)
53
+ URI.encode_www_form_component(value.to_s)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ class MeetingInfo < Model
6
+ def id
7
+ read("id")
8
+ end
9
+
10
+ def details
11
+ value = read("details")
12
+ value.is_a?(Hash) ? ActivityValue.new(value) : value
13
+ end
14
+
15
+ def conversation
16
+ value = read("conversation")
17
+ value ? ConversationAccount.new(value) : nil
18
+ end
19
+
20
+ def organizer
21
+ value = read("organizer")
22
+ value ? Account.new(value) : nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ class MeetingNotificationRecipientFailure < Model
6
+ def recipient_mri
7
+ read("recipientMri", "recipient_mri")
8
+ end
9
+
10
+ def error_code
11
+ read("errorCode", "error_code")
12
+ end
13
+
14
+ def failure_reason
15
+ read("failureReason", "failure_reason")
16
+ end
17
+ end
18
+
19
+ # Returned on partial success (HTTP 207) of a meeting notification; the
20
+ # client returns nil instead when every recipient succeeded (HTTP 202).
21
+ class MeetingNotificationResponse < Model
22
+ def recipients_failure_info
23
+ Array(read("recipientsFailureInfo", "recipients_failure_info")).map do |failure|
24
+ MeetingNotificationRecipientFailure.new(failure)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ # A participant's meeting-specific details: role and presence status.
6
+ class Meeting < Model
7
+ def role
8
+ read("role")
9
+ end
10
+
11
+ def in_meeting
12
+ read("inMeeting", "in_meeting")
13
+ end
14
+ end
15
+
16
+ class MeetingParticipant < Model
17
+ def user
18
+ value = read("user")
19
+ value ? Account.new(value) : nil
20
+ end
21
+
22
+ def meeting
23
+ value = read("meeting")
24
+ value ? Meeting.new(value) : nil
25
+ end
26
+
27
+ def conversation
28
+ value = read("conversation")
29
+ value ? ConversationAccount.new(value) : nil
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ class MessageActivity
6
+ TEXT_FORMATS = %w[plain markdown xml extendedmarkdown].freeze
7
+ FEEDBACK_MODES = %w[default custom].freeze
8
+ AI_MESSAGE_ENTITY_TYPE = "https://schema.org/Message"
9
+
10
+ attr_reader :id, :text, :attachments, :text_format, :summary, :input_hint, :entities, :channel_data, :recipient
11
+
12
+ def initialize(text = nil, id: nil, attachments: [], text_format: nil, summary: nil, input_hint: nil)
13
+ @id = id
14
+ @text = text
15
+ @attachments = attachments
16
+ @text_format = normalize_text_format(text_format)
17
+ @summary = summary
18
+ @input_hint = input_hint
19
+ @entities = []
20
+ @channel_data = {}
21
+ @recipient = nil
22
+ end
23
+
24
+ def add_text(value)
25
+ @text = "#{text}#{value}"
26
+ self
27
+ end
28
+
29
+ def add_quote(message_id, text = nil)
30
+ @entities << QuotedReplyEntity.new("quotedReply" => { "messageId" => message_id })
31
+ add_text(%(<quoted messageId="#{message_id}"/>))
32
+ add_text(" #{text}") if text
33
+ self
34
+ end
35
+
36
+ def prepend_quote(message_id)
37
+ @entities << QuotedReplyEntity.new("quotedReply" => { "messageId" => message_id })
38
+ placeholder = %(<quoted messageId="#{message_id}"/>)
39
+ @text = text.to_s.strip.empty? ? placeholder : "#{placeholder} #{text}"
40
+ self
41
+ end
42
+
43
+ def get_quoted_messages
44
+ entities.select { |entity| quoted_reply_entity?(entity) }
45
+ end
46
+
47
+ def add_card(card)
48
+ @attachments << {
49
+ "contentType" => "application/vnd.microsoft.card.adaptive",
50
+ "content" => card.respond_to?(:to_h) ? card.to_h : card
51
+ }
52
+ self
53
+ end
54
+
55
+ def with_text_format(text_format)
56
+ @text_format = normalize_text_format(text_format)
57
+ self
58
+ end
59
+
60
+ def with_id(id)
61
+ @id = id
62
+ self
63
+ end
64
+
65
+ def with_recipient(account, is_targeted: nil)
66
+ body = account.respond_to?(:to_h) ? account.to_h : account
67
+ body = body.dup
68
+ body["isTargeted"] = is_targeted unless is_targeted.nil?
69
+ @recipient = body
70
+ self
71
+ end
72
+
73
+ # Adds a targetedMessageInfo entity for prompt preview, once per message.
74
+ # Any quotedReply entities and the matching quote placeholder are removed
75
+ # to avoid collision with the prompt preview.
76
+ def add_targeted_message_info(message_id)
77
+ @entities.reject! { |entity| quoted_reply_entity?(entity) }
78
+ @text = text.gsub(%(<quoted messageId="#{message_id}"/>), "").strip if text
79
+
80
+ unless @entities.any? { |entity| entity_type(entity) == "targetedMessageInfo" }
81
+ @entities << { "type" => "targetedMessageInfo", "messageId" => message_id }
82
+ end
83
+
84
+ self
85
+ end
86
+
87
+ def add_ai_generated
88
+ entity = ensure_single_root_level_message_entity
89
+ additional_types = Array(entity["additionalType"])
90
+ return self if additional_types.include?("AIGeneratedContent")
91
+
92
+ entity["additionalType"] = additional_types + ["AIGeneratedContent"]
93
+ self
94
+ end
95
+
96
+ def add_mention(account, text: nil, add_text: true)
97
+ account_hash = account.respond_to?(:to_h) ? account.to_h : account
98
+ mention_text = text || account_hash["name"]
99
+
100
+ self.add_text("<at>#{mention_text}</at>") if add_text
101
+
102
+ @entities << {
103
+ "type" => "mention",
104
+ "mentioned" => account_hash,
105
+ "text" => "<at>#{mention_text}</at>"
106
+ }
107
+ self
108
+ end
109
+
110
+ # Adds a content sensitivity label. The label is carried as usageInfo on
111
+ # the root schema.org/Message entity, the wire shape TypeScript, Python,
112
+ # and the Teams documentation use.
113
+ def add_sensitivity_label(name, description: nil, pattern: nil)
114
+ usage_info = {
115
+ "type" => AI_MESSAGE_ENTITY_TYPE,
116
+ "@type" => "CreativeWork",
117
+ "name" => name
118
+ }
119
+ usage_info["description"] = description if description
120
+ usage_info["pattern"] = pattern if pattern
121
+
122
+ ensure_single_root_level_message_entity["usageInfo"] = usage_info
123
+ self
124
+ end
125
+
126
+ def add_citation(position, appearance)
127
+ entity = ensure_single_root_level_message_entity
128
+ entity["citation"] ||= []
129
+ entity["citation"] << {
130
+ "@type" => "Claim",
131
+ "position" => position,
132
+ "appearance" => citation_appearance(appearance).to_h
133
+ }
134
+ self
135
+ end
136
+
137
+ def add_feedback(mode = "default")
138
+ @channel_data["feedbackLoop"] = { "type" => normalize_feedback_mode(mode) }
139
+ @channel_data.delete("feedbackLoopEnabled")
140
+ self
141
+ end
142
+
143
+ def to_h
144
+ body = { "type" => "message" }
145
+ body["id"] = id if id
146
+ body["text"] = text if text
147
+ body["textFormat"] = text_format if text_format
148
+ body["summary"] = summary if summary
149
+ body["inputHint"] = input_hint if input_hint
150
+ body["attachments"] = attachments unless attachments.empty?
151
+ body["entities"] = @entities.map { |entity| entity.respond_to?(:to_h) ? entity.to_h : entity } unless @entities.empty?
152
+ body["channelData"] = channel_data unless channel_data.empty?
153
+ body["recipient"] = recipient if recipient
154
+ body
155
+ end
156
+
157
+ private
158
+
159
+ def quoted_reply_entity?(entity)
160
+ entity_type(entity) == "quotedReply"
161
+ end
162
+
163
+ def ensure_single_root_level_message_entity
164
+ entity = @entities.find { |item| entity_type(item) == AI_MESSAGE_ENTITY_TYPE }
165
+ return entity if entity
166
+
167
+ entity = {
168
+ "type" => AI_MESSAGE_ENTITY_TYPE,
169
+ "@type" => "Message",
170
+ "@context" => "https://schema.org"
171
+ }
172
+ @entities << entity
173
+ entity
174
+ end
175
+
176
+ def citation_appearance(appearance)
177
+ appearance.is_a?(CitationAppearance) ? appearance : CitationAppearance.new(appearance || {})
178
+ end
179
+
180
+ def entity_type(entity)
181
+ entity.respond_to?(:type) ? entity.type : entity["type"]
182
+ end
183
+
184
+ def normalize_text_format(value)
185
+ return nil if value.nil?
186
+
187
+ normalized = value.to_s
188
+ return normalized if TEXT_FORMATS.include?(normalized)
189
+
190
+ raise ArgumentError, "text_format must be one of: #{TEXT_FORMATS.join(", ")}"
191
+ end
192
+
193
+ def normalize_feedback_mode(value)
194
+ normalized = value.to_s
195
+ return normalized if FEEDBACK_MODES.include?(normalized)
196
+
197
+ raise ArgumentError, "feedback mode must be one of: #{FEEDBACK_MODES.join(", ")}"
198
+ end
199
+ end
200
+ end
201
+ end