diff --git a/assets/public/setup-guides/chatbots/teams.md b/assets/public/setup-guides/chatbots/teams.md new file mode 100644 index 0000000000..0b1dbecffc --- /dev/null +++ b/assets/public/setup-guides/chatbots/teams.md @@ -0,0 +1,47 @@ +# Microsoft Teams chatbot setup + +Connecting a Microsoft Teams chatbot lets a workbench respond when your bot is @mentioned in a Teams channel. +Unlike Slack (which uses an outbound Socket Mode websocket), Teams delivers messages to Plural through an +inbound **Bot Framework webhook**, so the steps below wire Azure Bot Service to Plural's messaging endpoint. + +## 1. Register an Azure Bot + +1. In the [Azure Portal](https://portal.azure.com), create an **Azure Bot** resource. +2. Choose (or create) a **Microsoft App** for the bot. Note the **Application (client) ID** and **tenant ID**. +3. Under **Certificates & secrets** for that app registration, create a **client secret** and copy the value. + +## 2. Point the bot at Plural + +1. Create the chatbot connection in Plural first (step 3) so you have its id. +2. In the Azure Bot's **Configuration**, set the **Messaging endpoint** to: + + ``` + https:///ext/v1/webhooks/teams/ + ``` + +3. Enable the **Microsoft Teams** channel on the bot. + +## 3. Create the connection in Plural + +Fill in the connection form with: + +- **Application (client) ID** — the Microsoft App ID from step 1. This is also used to validate the JWT on + every inbound request. +- **Client secret** — the secret from step 1. Used to mint Bot Framework connector tokens (for replies) and + Microsoft Graph tokens (for channel/team lookups). +- **Directory (tenant) ID** — the Azure AD tenant the bot is registered in. + +## 4. Grant Graph permissions (for channel discovery) + +The connection lists teams and channels via Microsoft Graph so you can bind a workbench to a channel. Grant the +app registration application permissions such as **Team.ReadBasic.All** and **Channel.ReadBasic.All**, then have +an admin **grant admin consent**. + +## 5. Bind a workbench to a channel + +Create a workbench chatbot that targets this connection and the channel you want the bot to listen in. When a +user @mentions the bot in that channel, Plural spawns a workbench job and the agent replies in the conversation +via the Bot Framework connector. + +> Note: Microsoft Teams does not support bots setting emoji reactions on messages, so the bot acknowledges and +> responds with messages rather than reactions. diff --git a/assets/src/components/settings/chatbots/ChatbotConnectionForm.tsx b/assets/src/components/settings/chatbots/ChatbotConnectionForm.tsx index f51e1c836d..ef7fbc857e 100644 --- a/assets/src/components/settings/chatbots/ChatbotConnectionForm.tsx +++ b/assets/src/components/settings/chatbots/ChatbotConnectionForm.tsx @@ -20,7 +20,7 @@ import { useUpsertChatProviderConnectionMutation, } from 'generated/graphql' import { isEqual } from 'lodash' -import { useMemo, useState } from 'react' +import { Dispatch, SetStateAction, useMemo, useState } from 'react' import { useLocation, useNavigate } from 'react-router-dom' import { CHATBOTS_SETTINGS_ABS_PATH } from 'routes/settingsRoutesConst' import { WORKBENCHES_CHATBOT_SELECTED_QUERY_PARAM } from 'routes/workbenchesRoutesConsts' @@ -40,12 +40,23 @@ type RouteState = { type ChatbotConnectionFormState = { name: string + type: ChatProviderConnectionType + // slack appToken: string botToken: string + // teams + clientId: string + clientSecret: string + tenantId: string readBindings: PolicyBindingFragment[] writeBindings: PolicyBindingFragment[] } +const SUPPORTED_TYPES = [ + ChatProviderConnectionType.Slack, + ChatProviderConnectionType.Teams, +] as const + export function ChatbotConnectionForm({ existingConnection, }: { @@ -64,38 +75,21 @@ export function ChatbotConnectionForm({ const [formState, setFormState] = useState(initialFormState) + const isTeams = formState.type === ChatProviderConnectionType.Teams const name = formState.name.trim() - const appToken = formState.appToken.trim() - const botToken = formState.botToken.trim() - const hasTokenUpdate = !!appToken || !!botToken - const hasCompleteTokenUpdate = !!appToken && !!botToken + + const attributes = useMemo( + () => buildAttributes(formState, mode), + [formState, mode] + ) const canSave = !!name && - (mode === 'create' - ? hasCompleteTokenUpdate - : !hasTokenUpdate || hasCompleteTokenUpdate) && + attributes.configComplete && (mode === 'create' || !isEqual(formState, initialFormState)) - const attributes: ChatProviderConnectionAttributes = { - name, - type: ChatProviderConnectionType.Slack, - configuration: { - ...(mode === 'create' || hasTokenUpdate - ? { - slack: { - appToken, - botToken, - }, - } - : {}), - }, - readBindings: formState.readBindings.map(bindingToBindingAttributes), - writeBindings: formState.writeBindings.map(bindingToBindingAttributes), - } - const [upsertChatProviderConnection, { loading, error }] = useUpsertChatProviderConnectionMutation({ - variables: { attributes }, + variables: { attributes: attributes.attributes }, onCompleted: ({ upsertChatProviderConnection }) => { const label = upsertChatProviderConnection?.name ?? 'chatbot' @@ -139,26 +133,30 @@ export function ChatbotConnectionForm({ required layout="horizontal" label="Chat platform" - hint="Only Slack is supported for Workbench chatbots currently." + hint={ + mode === 'edit' + ? 'The chat platform cannot be changed after creation.' + : 'Select the chat platform this connection integrates with.' + } > - App-Level Tokens with connections:write. Do not paste the xoxb- bot token here.' - } - > - - setFormState((prev) => ({ - ...prev, - appToken: e.target.value, - })) - } - inputProps={{ type: 'password' }} - disabled={loading} + {isTeams ? ( + - - - - setFormState((prev) => ({ - ...prev, - botToken: e.target.value, - })) - } - inputProps={{ type: 'password' }} - disabled={loading} + ) : ( + - + )} > + mode: 'create' | 'edit' + loading: boolean +}) { + return ( + <> + App-Level Tokens with connections:write. Do not paste the xoxb- bot token here.' + } + > + + setFormState((prev) => ({ ...prev, appToken: e.target.value })) + } + inputProps={{ type: 'password' }} + disabled={loading} + /> + + + + setFormState((prev) => ({ ...prev, botToken: e.target.value })) + } + inputProps={{ type: 'password' }} + disabled={loading} + /> + + + ) +} + +function TeamsFields({ + formState, + setFormState, + mode, + loading, +}: { + formState: ChatbotConnectionFormState + setFormState: Dispatch> + mode: 'create' | 'edit' + loading: boolean +}) { + return ( + <> + + + setFormState((prev) => ({ ...prev, clientId: e.target.value })) + } + disabled={loading} + /> + + + + setFormState((prev) => ({ ...prev, clientSecret: e.target.value })) + } + inputProps={{ type: 'password' }} + disabled={loading} + /> + + + + setFormState((prev) => ({ ...prev, tenantId: e.target.value })) + } + disabled={loading} + /> + + + ) +} + +function buildAttributes( + formState: ChatbotConnectionFormState, + mode: 'create' | 'edit' +): { attributes: ChatProviderConnectionAttributes; configComplete: boolean } { + const name = formState.name.trim() + const base = { + name, + type: formState.type, + readBindings: formState.readBindings.map(bindingToBindingAttributes), + writeBindings: formState.writeBindings.map(bindingToBindingAttributes), + } + + if (formState.type === ChatProviderConnectionType.Teams) { + const clientId = formState.clientId.trim() + const clientSecret = formState.clientSecret.trim() + const tenantId = formState.tenantId.trim() + const hasFullConfig = !!clientId && !!clientSecret && !!tenantId + const hasSecretUpdate = !!clientSecret + + return { + attributes: { + ...base, + configuration: hasSecretUpdate + ? { teams: { clientId, clientSecret, tenantId } } + : {}, + }, + configComplete: + mode === 'create' ? hasFullConfig : !hasSecretUpdate || hasFullConfig, + } + } + + const appToken = formState.appToken.trim() + const botToken = formState.botToken.trim() + const hasTokenUpdate = !!appToken || !!botToken + const hasCompleteTokenUpdate = !!appToken && !!botToken + + return { + attributes: { + ...base, + configuration: + mode === 'create' || hasTokenUpdate + ? { slack: { appToken, botToken } } + : {}, + }, + configComplete: + mode === 'create' + ? hasCompleteTokenUpdate + : !hasTokenUpdate || hasCompleteTokenUpdate, + } +} + function getInitialFormState( existingConnection?: Nullable ): ChatbotConnectionFormState { return { name: existingConnection?.name ?? '', + type: existingConnection?.type ?? ChatProviderConnectionType.Slack, appToken: '', botToken: '', + clientId: existingConnection?.configuration?.teams?.clientId ?? '', + clientSecret: '', + tenantId: existingConnection?.configuration?.teams?.tenantId ?? '', readBindings: existingConnection?.readBindings?.filter(isNonNullable) ?? [], writeBindings: existingConnection?.writeBindings?.filter(isNonNullable) ?? [], diff --git a/assets/src/components/settings/chatbots/chatbotSetupGuide.ts b/assets/src/components/settings/chatbots/chatbotSetupGuide.ts index 4306efb83a..c02275d724 100644 --- a/assets/src/components/settings/chatbots/chatbotSetupGuide.ts +++ b/assets/src/components/settings/chatbots/chatbotSetupGuide.ts @@ -1,4 +1,26 @@ +import { ChatProviderConnectionType } from 'generated/graphql' + export const SLACK_CHATBOT_SETUP_GUIDE_MARKDOWN_PATH = '/setup-guides/chatbots/slack.md' export const SLACK_CHATBOT_SETUP_GUIDE_DOCUMENTATION_URL = 'https://api.slack.com/apis/connections/socket' + +export const TEAMS_CHATBOT_SETUP_GUIDE_MARKDOWN_PATH = + '/setup-guides/chatbots/teams.md' +export const TEAMS_CHATBOT_SETUP_GUIDE_DOCUMENTATION_URL = + 'https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication' + +export function chatbotSetupGuide(type?: Nullable) { + switch (type) { + case ChatProviderConnectionType.Teams: + return { + documentationUrl: TEAMS_CHATBOT_SETUP_GUIDE_DOCUMENTATION_URL, + markdownPath: TEAMS_CHATBOT_SETUP_GUIDE_MARKDOWN_PATH, + } + default: + return { + documentationUrl: SLACK_CHATBOT_SETUP_GUIDE_DOCUMENTATION_URL, + markdownPath: SLACK_CHATBOT_SETUP_GUIDE_MARKDOWN_PATH, + } + } +} diff --git a/lib/console/ai/tools/workbench/integration/teams/reply.ex b/lib/console/ai/tools/workbench/integration/teams/reply.ex new file mode 100644 index 0000000000..6c6c229436 --- /dev/null +++ b/lib/console/ai/tools/workbench/integration/teams/reply.ex @@ -0,0 +1,52 @@ +defmodule Console.AI.Tools.Workbench.Integration.Teams.Reply do + @moduledoc """ + Replies to the Teams conversation that triggered this workbench chatbot job, via the Bot Framework connector. + + Reply coordinates (`serviceUrl`/`conversationId`) are read from the job's persisted `ChatbotMessage`, so the + agent only needs to supply the response `text` - it does not need channel or message ids. This is the correct + way to respond to a mention; application-only Microsoft Graph posting is not available for standard channels. + """ + use Console.AI.Tools.Workbench.Base + alias Console.Schema.{WorkbenchTool, WorkbenchJob, ChatbotMessage} + alias Console.Schema.WorkbenchTool.{Configuration, Configuration.TeamsConnection} + alias Console.Chat.Teams.Connector + + embedded_schema do + field :tool, :map, virtual: true + field :text, :string + end + + @json_schema Console.priv_file!("tools/workbench/integration/teams/reply.json") |> Jason.decode!() + + def name(%__MODULE__{tool: %WorkbenchTool{name: name}}), do: "teams_reply_#{name}" + + def description(%__MODULE__{tool: %WorkbenchTool{name: name}}), + do: + "Reply in the Teams conversation that triggered this job for #{name}. Posts `text` as a threaded reply to the original mention via the Bot Framework connector. Use this to respond to the user - you do not need channel or message ids." + + def json_schema(%__MODULE__{}), do: @json_schema + + def changeset(%__MODULE__{} = model, attrs) do + model + |> cast(attrs, [:text]) + |> validate_required([:text]) + end + + def implement(%__MODULE__{tool: %WorkbenchTool{configuration: %Configuration{teams: %TeamsConnection{} = conn}}, text: text}) do + with %ChatbotMessage{service_url: url, conversation_id: cid} when is_binary(url) and is_binary(cid) <- reply_context(), + {:ok, resp} <- Connector.reply(conn, url, cid, text) do + Jason.encode(resp) + else + {:error, _} = err -> err + _ -> {:error, "no teams chat context is available for this job; use teams_post_channel_message with explicit ids instead"} + end + end + def implement(%__MODULE__{}), do: {:error, "Microsoft Teams app registration is not configured for this workbench tool."} + + defp reply_context() do + case Console.AI.Tool.context() do + %Console.AI.Tool.Context{job: %WorkbenchJob{chatbot_message: %ChatbotMessage{} = msg}} -> msg + _ -> nil + end + end +end diff --git a/lib/console/ai/tools/workbench/integration/teams/tools.ex b/lib/console/ai/tools/workbench/integration/teams/tools.ex index 8ef2cd2a74..362946352c 100644 --- a/lib/console/ai/tools/workbench/integration/teams/tools.ex +++ b/lib/console/ai/tools/workbench/integration/teams/tools.ex @@ -12,7 +12,8 @@ defmodule Console.AI.Tools.Workbench.Integration.Teams.Tools do Console.AI.Tools.Workbench.Integration.Teams.SearchGroups, Console.AI.Tools.Workbench.Integration.Teams.PostChannelMessage, Console.AI.Tools.Workbench.Integration.Teams.UpdateChannelMessage, - Console.AI.Tools.Workbench.Integration.Teams.ReactToChannelMessage + Console.AI.Tools.Workbench.Integration.Teams.ReactToChannelMessage, + Console.AI.Tools.Workbench.Integration.Teams.Reply ] @spec expand(WorkbenchTool.t()) :: [struct()] diff --git a/lib/console/chat/impl.ex b/lib/console/chat/impl.ex index 64ed1b993f..d21b1669e9 100644 --- a/lib/console/chat/impl.ex +++ b/lib/console/chat/impl.ex @@ -3,7 +3,7 @@ defmodule Console.Chat.Impl do A thin behaviour to define a chat provider implementation. """ alias Console.Schema.ChatConnection - alias Console.Chat.{Channel, Impl.Slack} + alias Console.Chat.{Channel, Impl.Slack, Impl.Teams} defmacro __using__(_opts) do quote do @@ -25,5 +25,6 @@ defmodule Console.Chat.Impl do end defp provider(%ChatConnection{type: :slack}), do: {:ok, Slack} + defp provider(%ChatConnection{type: :teams}), do: {:ok, Teams} defp provider(%ChatConnection{type: type}), do: {:error, "#{type} chat search is not implemented"} end diff --git a/lib/console/chat/impl/teams.ex b/lib/console/chat/impl/teams.ex new file mode 100644 index 0000000000..1a094ac37c --- /dev/null +++ b/lib/console/chat/impl/teams.ex @@ -0,0 +1,120 @@ +defmodule Console.Chat.Impl.Teams do + @moduledoc """ + Microsoft Teams chat provider. + + Unlike Slack (which holds an outbound Socket Mode websocket per connection), Teams has no bot-initiated + socket for messaging, so inbound activities arrive via a Bot Framework webhook (see + `ConsoleWeb.WebhookController.teams/2`). This module therefore has no long-lived process - `child_spec/1` + is intentionally unimplemented. It parses inbound activities, matches bot mentions and delegates to + `Console.Chat.Utils.handle_mention/4`, and implements channel search for the connection UI. + """ + use Console.Chat.Impl + alias Console.Chat.{Channel, Utils, Reference} + alias Console.AI.Tools.Workbench.Integration.Teams.{Client, TokenExchange} + require Logger + + @mention "mention" + @team_filter "resourceProvisioningOptions/Any(c:c eq 'Team')" + @team_limit 50 + + @doc """ + Handles an inbound Bot Framework activity, spawning a workbench job when the bot is mentioned in a message. + """ + @spec handle_activity(ChatConnection.t(), map) :: :ok + def handle_activity(%ChatConnection{} = conn, %{"type" => "message"} = activity) do + case mentioned?(activity) do + true -> spawn_job(conn, activity) + false -> :ok + end + end + def handle_activity(_, _), do: :ok + + @impl true + def child_spec(%ChatConnection{}), + do: {:error, "teams chat connections are served via inbound webhook, not a supervised process"} + + @impl true + def search_channels(%ChatConnection{type: :teams, configuration: %{teams: %{client_id: cid, client_secret: secret, tenant_id: tid}}}, query) + when is_binary(cid) and is_binary(secret) and is_binary(tid) do + with {:ok, client} <- TokenExchange.exchange(cid, secret, tid), + {:ok, %{"value" => teams}} <- Client.get(client, "/groups", team_params()) do + teams + |> Enum.flat_map(&team_channels(client, &1)) + |> filter(query) + |> then(& {:ok, &1}) + else + err -> {:error, "failed to list teams channels: #{inspect(err)}"} + end + end + def search_channels(%ChatConnection{}, _), do: {:error, "Microsoft Teams app registration is not configured"} + + defp team_params() do + %{ + "$filter" => @team_filter, + "$select" => "id,displayName", + "$orderby" => "displayName", + "$top" => @team_limit + } + end + + defp team_channels(client, %{"id" => team_id, "displayName" => team_name}) do + case Client.get(client, "/teams/#{URI.encode(team_id, &URI.char_unreserved?/1)}/channels", %{"$select" => "id,displayName"}) do + {:ok, %{"value" => channels}} -> + Enum.map(channels, & %Channel{id: &1["id"], name: "#{team_name} / #{&1["displayName"]}"}) + _ -> [] + end + end + defp team_channels(_, _), do: [] + + defp mentioned?(%{"recipient" => %{"id" => bot_id}, "entities" => entities}) when is_list(entities) and is_binary(bot_id) do + Enum.any?(entities, fn + %{"type" => @mention, "mentioned" => %{"id" => ^bot_id}} -> true + _ -> false + end) + end + defp mentioned?(_), do: false + + defp spawn_job(%ChatConnection{} = conn, activity) do + msg = %Reference{id: activity["id"], text: clean_text(activity)} + channel = %Reference{id: channel_id(activity), text: channel_id(activity)} + + extra = %{ + service_url: activity["serviceUrl"], + conversation_id: conversation_id(activity), + activity_id: activity["id"] + } + + case Utils.handle_mention(msg, channel, conn, extra) do + {:ok, _} -> :ok + :ok -> :ok + err -> + Logger.error("failed to spawn teams job: #{inspect(err)}") + :ok + end + end + + # the channel id (join key for WorkbenchChatbot) - a stable `19:...@thread.tacv2` id, distinct from the + # conversation id which also carries the thread/message context used when replying. + defp channel_id(%{"channelData" => %{"channel" => %{"id" => id}}}) when is_binary(id), do: id + defp channel_id(%{"channelData" => %{"teamsChannelId" => id}}) when is_binary(id), do: id + defp channel_id(activity), do: conversation_id(activity) + + defp conversation_id(%{"conversation" => %{"id" => id}}) when is_binary(id), do: id + defp conversation_id(_), do: nil + + defp clean_text(%{"text" => text}) when is_binary(text) do + text + |> String.replace(~r/]*>.*?<\/at>/s, "") + |> String.trim() + end + defp clean_text(_), do: "" + + defp filter(channels, query) when is_binary(query) do + case String.trim(query) |> String.downcase() do + q when byte_size(q) > 0 -> + Enum.filter(channels, &String.contains?(String.downcase(&1.name || ""), q)) + _ -> channels + end + end + defp filter(channels, _), do: channels +end diff --git a/lib/console/chat/registrar.ex b/lib/console/chat/registrar.ex index e916540f13..b116b3cf22 100644 --- a/lib/console/chat/registrar.ex +++ b/lib/console/chat/registrar.ex @@ -33,9 +33,11 @@ defmodule Console.Chat.Registrar do def local?(%ChatConnection{id: id}), do: Console.ClusterRing.node(id) == node() + # only slack connections run as supervised bots (outbound socket). teams is served via inbound webhook and + # has no long-lived process, so it is skipped here. defp start_chats(chats) do chats - |> Enum.filter(&local?/1) + |> Enum.filter(&(local?(&1) and &1.type == :slack)) |> Enum.reduce(%{}, fn chat, acc -> case DynamicSupervisor.start_child(chat) do {:ok, pid} -> Map.put(acc, chat.id, pid) diff --git a/lib/console/chat/teams/auth.ex b/lib/console/chat/teams/auth.ex new file mode 100644 index 0000000000..1ace307044 --- /dev/null +++ b/lib/console/chat/teams/auth.ex @@ -0,0 +1,76 @@ +defmodule Console.Chat.Teams.Auth do + @moduledoc """ + Validates inbound Bot Framework JWTs sent by the Azure Bot Service (channel -> bot). + + Follows the Bot Connector authentication spec: + https://learn.microsoft.com/en-us/azure/bot-service/rest-api/bot-framework-rest-connector-authentication + + Teams authenticates channel -> bot webhooks with a signed OIDC JWT in the `Authorization` header - there is no + hmac/shared-secret option like the scm/observability webhooks use - so we validate the token against the + connector's published JWKS. We reuse `oidcc` (as the OIDC login flow does in `Console.Deployments.Settings`) + to discover the metadata, load + cache the JWKS, and validate the signature/issuer/audience/lifetime. The + Teams-specific `serviceurl` claim is checked separately. + """ + alias Console.OIDC.ProviderConfiguration + + # The connector publishes its metadata here. Two quirks are needed to consume it via oidcc: + # * the document's declared issuer is `https://api.botframework.com` (not this url) -> allow_issuer_mismatch + # * the document omits several oidc-required fields (scopes/response_types/subject_types), so we backfill + # them via document_overrides purely to satisfy the parser - they do not affect token validation. + @config_issuer "https://login.botframework.com/v1" + + @quirks %{ + quirks: %{ + allow_issuer_mismatch: true, + document_overrides: %{ + "scopes_supported" => ["openid"], + "response_types_supported" => ["id_token"], + "subject_types_supported" => ["public"] + } + } + } + + @type claims :: map() + + @doc """ + Verifies a Bot Framework JWT. `audience` must be the bot's Microsoft App ID (the connection's client id). + Pass `service_url:` to additionally pin the token's `serviceurl` claim to the inbound activity's serviceUrl. + """ + @spec verify(binary, binary, keyword) :: {:ok, claims} | {:error, binary} + def verify(token, audience, opts \\ []) + def verify(token, audience, opts) when is_binary(token) and is_binary(audience) do + with {:ok, _} <- peek(token), + {:ok, {conf, jwks}} <- ProviderConfiguration.fetch(@config_issuer, @quirks), + ctx = Oidcc.ClientContext.from_manual(conf, jwks, audience, "dummy_secret", %{client_jwks: JOSE.JWK.generate_key(16)}), + validate_opts = %{signing_algs: ctx.provider_configuration.id_token_signing_alg_values_supported}, + {:ok, claims} <- validate_jwt(token, ctx, validate_opts), + :ok <- validate_service_url(claims, opts[:service_url]) do + {:ok, claims} + end + end + def verify(_, _, _), do: {:error, "missing teams bot token or audience"} + + # cheap, network-free rejection of obviously malformed tokens before we touch the provider config + defp peek(token) do + case Joken.peek_header(token) do + {:ok, _} = ok -> ok + _ -> {:error, "malformed teams jwt"} + end + end + + defp validate_jwt(token, ctx, opts) do + case Oidcc.Token.validate_jwt(token, ctx, opts) do + {:ok, claims} -> {:ok, claims} + {:error, err} -> {:error, "invalid teams jwt: #{inspect(err)}"} + end + end + + # when no service_url is supplied there's nothing to pin against; otherwise the claim must be present and match. + defp validate_service_url(_claims, url) when not is_binary(url), do: :ok + defp validate_service_url(%{"serviceurl" => claim}, url) when is_binary(claim), + do: check(String.trim_trailing(claim, "/") == String.trim_trailing(url, "/"), "teams jwt serviceUrl mismatch") + defp validate_service_url(_claims, _url), do: {:error, "teams jwt is missing the serviceUrl claim"} + + defp check(true, _), do: :ok + defp check(_, msg), do: {:error, msg} +end diff --git a/lib/console/chat/teams/connector.ex b/lib/console/chat/teams/connector.ex new file mode 100644 index 0000000000..4264cd03f6 --- /dev/null +++ b/lib/console/chat/teams/connector.ex @@ -0,0 +1,37 @@ +defmodule Console.Chat.Teams.Connector do + @moduledoc """ + Posts replies to Teams via the Bot Framework connector API + (`POST {serviceUrl}/v3/conversations/{conversationId}/activities`). + + This is the correct outbound path for a bot responding to a mention - application-only Microsoft Graph + posting to standard channels is restricted by Microsoft to migration scenarios, whereas the connector is + purpose-built for bot replies and threads into the originating conversation automatically. + """ + alias Console.Chat.Teams.Token + alias Console.Schema.WorkbenchTool.Configuration.TeamsConnection + + @spec reply(struct(), binary, binary, binary) :: {:ok, map} | {:error, binary} + def reply(%TeamsConnection{client_id: cid, client_secret: secret, tenant_id: tid}, service_url, conversation_id, text) + when is_binary(service_url) and is_binary(conversation_id) and is_binary(text) do + with {:ok, token} <- Token.connector_token(cid, secret, tid) do + url = activities_url(service_url, conversation_id) + post(url, token, %{"type" => "message", "text" => text}) + end + end + def reply(_, _, _, _), do: {:error, "teams reply is missing connection config or reply coordinates"} + + defp activities_url(service_url, conversation_id) do + "#{String.trim_trailing(service_url, "/")}/v3/conversations/#{URI.encode(conversation_id, &URI.char_unreserved?/1)}/activities" + end + + defp post(url, token, body) do + case Req.post(url, auth: {:bearer, token}, json: body) do + {:ok, %Req.Response{status: s, body: body}} when s in 200..299 -> {:ok, ensure_map(body)} + {:ok, %Req.Response{status: s, body: body}} -> {:error, "teams connector returned #{s}: #{inspect(body)}"} + {:error, err} -> {:error, "teams connector request failed: #{inspect(err)}"} + end + end + + defp ensure_map(body) when is_map(body), do: body + defp ensure_map(_), do: %{} +end diff --git a/lib/console/chat/teams/token.ex b/lib/console/chat/teams/token.ex new file mode 100644 index 0000000000..bc97b450db --- /dev/null +++ b/lib/console/chat/teams/token.ex @@ -0,0 +1,52 @@ +defmodule Console.Chat.Teams.Token do + @moduledoc """ + Mints and caches a Bot Framework connector token (to-channel-from-bot) used to authenticate outbound replies + posted to a conversation `serviceUrl`. This is distinct from the Microsoft Graph token minted for the + workbench teams tools - it is scoped to `https://api.botframework.com/.default`. + + Note: single-tenant bots authenticate against their tenant authority (what we do here). Multi-tenant bots + should instead use the `botframework.com` authority; adjust the stored tenant id accordingly if needed. + """ + import OAuth2.Util, only: [unix_now: 0] + alias Console.Cache + alias OAuth2.{Client, Strategy.ClientCredentials} + + @scope "https://api.botframework.com/.default" + + @spec connector_token(binary, binary, binary) :: {:ok, binary} | {:error, binary} + def connector_token(client_id, client_secret, tenant_id) + when is_binary(client_id) and is_binary(client_secret) and is_binary(tenant_id) do + case Cache.get(cache_key(client_id, tenant_id)) do + %OAuth2.AccessToken{access_token: token} when is_binary(token) -> {:ok, token} + _ -> refresh(client_id, client_secret, tenant_id) + end + end + def connector_token(_, _, _), do: {:error, "Microsoft Teams app registration is not configured"} + + defp refresh(client_id, client_secret, tenant_id) do + client_base(client_id, client_secret, tenant_id) + |> Client.get_token() + |> case do + {:ok, %Client{token: %OAuth2.AccessToken{access_token: token, expires_at: expires_at} = at}} -> + Cache.put(cache_key(client_id, tenant_id), at, ttl: expiry(expires_at)) + {:ok, token} + {:error, err} -> {:error, "failed to exchange teams connector token: #{inspect(err)}"} + end + end + + defp client_base(client_id, client_secret, tenant_id) do + Client.new( + strategy: ClientCredentials, + site: "https://api.botframework.com", + client_id: client_id, + client_secret: client_secret, + scope: @scope, + token_url: "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token" + ) + end + + defp cache_key(client_id, tenant_id), do: {:teams_connector_token, tenant_id, client_id} + + defp expiry(expires_at) when is_integer(expires_at), do: expires_at - unix_now() + defp expiry(_), do: :timer.minutes(15) +end diff --git a/lib/console/chat/utils.ex b/lib/console/chat/utils.ex index 348f085089..c97d6fc73e 100644 --- a/lib/console/chat/utils.ex +++ b/lib/console/chat/utils.ex @@ -20,24 +20,36 @@ defmodule Console.Chat.Utils do def cache_msg(%ChatbotMessage{} = msg), do: @cache.put({:chatbot_msg, cache_id(msg)}, msg, ttl: :timer.hours(24)) - def handle_mention(%Reference{id: external_id} = msg, %Reference{text: channel} = chan_ref, %ChatConnection{id: id} = conn) do + def handle_mention(%Reference{} = msg, %Reference{} = chan_ref, %ChatConnection{} = conn), + do: handle_mention(msg, chan_ref, conn, %{}) + + @doc """ + Looks up the workbench chatbot bound to the mentioned channel and, if found, spawns a workbench job for the + request (or appends to the in-flight parent job when the mention is a threaded reply). `extra` is merged into + the persisted `ChatbotMessage`, letting providers stash reply coordinates (e.g. teams + `service_url`/`conversation_id`/`activity_id`) needed to respond out-of-band. + """ + def handle_mention(%Reference{id: external_id} = msg, %Reference{text: channel} = chan_ref, %ChatConnection{id: id} = conn, %{} = extra) do bot = Workbenches.workbench_chatbot(id, channel) |> Repo.preload([user: [:groups]]) with %WorkbenchChatbot{user: %User{} = user, prompt: prompt, message_behavior: behavior} = chatbot <- bot do prompt = prompt(chat: conn, msg: msg, channel: chan_ref, custom: prompt, behavior: behavior) case parent_job(msg) do %WorkbenchJob{} = job -> Workbenches.create_message(%{prompt: prompt}, job, user) _ -> - Workbenches.create_workbench_job(%{ - prompt: prompt, - workbench_id: chatbot.workbench_id, - modes: Console.mapify(chatbot.modes), - chatbot_message: %{ + chatbot_message = + Map.merge(%{ message: msg.text, channel: channel, chat_connection_id: id, external_id: external_id, external_parent_id: msg.parent_id - } + }, extra) + + Workbenches.create_workbench_job(%{ + prompt: prompt, + workbench_id: chatbot.workbench_id, + modes: Console.mapify(chatbot.modes), + chatbot_message: chatbot_message }, chatbot.workbench_id, user) end else diff --git a/lib/console/deployments/settings.ex b/lib/console/deployments/settings.ex index ca9e362192..2d8e24f569 100644 --- a/lib/console/deployments/settings.ex +++ b/lib/console/deployments/settings.ex @@ -431,18 +431,10 @@ defmodule Console.Deployments.Settings do } @doc """ - Fetches the issuer configuration from the issuer url + Fetches (and caches) the issuer configuration + jwks from the issuer url """ - @decorate cacheable( - cache: @cache_adapter, - key: {:issuer_configuration, issuer}, - opts: [ttl: :timer.minutes(60)] - ) - def issuer_configuration(issuer) do - with {:ok, {conf, _}} <- Oidcc.ProviderConfiguration.load_configuration(issuer, @quirks), - {:ok, {jwks, _}} <- Oidcc.ProviderConfiguration.load_jwks(conf.jwks_uri), - do: {:ok, {conf, jwks}} - end + def issuer_configuration(issuer), + do: Console.OIDC.ProviderConfiguration.fetch(issuer, @quirks) @decorate cache_evict(cache: @cache_adapter, key: :deployment_settings) def update(attrs) do diff --git a/lib/console/oidc/provider_configuration.ex b/lib/console/oidc/provider_configuration.ex new file mode 100644 index 0000000000..3b03cb588a --- /dev/null +++ b/lib/console/oidc/provider_configuration.ex @@ -0,0 +1,58 @@ +defmodule Console.OIDC.ProviderConfiguration do + @moduledoc """ + Fetches and caches an OpenID Connect provider's configuration + JWKS for an issuer. + + `oidcc` normally recommends running a supervised `Oidcc.ProviderConfiguration.Worker` per provider (an in-memory + GenServer that background-refreshes). We instead cache through Nebulex so the result is shared across the + cluster and we don't need a worker per issuer in the supervision tree. The cache ttl is derived from the + `Cache-Control` max-age the provider advertises on its metadata/JWKS responses (`oidcc` returns this as a + relative millisecond expiry, falling back to its own 15m default), so entries refresh about when the signing + keys would otherwise go stale. We take the sooner of the config/jwks expiries (jwks rotation is what matters + for signature validation) and clamp it to a sane window. + + Callers pass their own `quirks` opts map (`%{quirks: %{...}}`) for atypical providers - e.g. document overrides + or `allow_issuer_mismatch` - since those are provider-specific. + """ + require Logger + + @cache_adapter Console.conf(:cache_adapter) + @min_ttl :timer.minutes(5) + @max_ttl :timer.hours(24) + + @type provider :: {Oidcc.ProviderConfiguration.t(), :jose_jwk.key()} + + @doc """ + Loads (and caches) the provider configuration + jwks for `issuer`. `quirks` is forwarded to + `Oidcc.ProviderConfiguration.load_configuration/2`. + """ + @spec fetch(binary, map) :: {:ok, provider} | {:error, term} + def fetch(issuer, quirks \\ %{}) when is_binary(issuer) do + case @cache_adapter.get(key(issuer)) do + {_conf, _jwks} = hit -> {:ok, hit} + _ -> refresh(issuer, quirks) + end + end + + defp refresh(issuer, quirks) do + with {:ok, {conf, conf_exp}} <- Oidcc.ProviderConfiguration.load_configuration(issuer, quirks), + {:ok, {jwks, jwks_exp}} <- Oidcc.ProviderConfiguration.load_jwks(conf.jwks_uri) do + @cache_adapter.put(key(issuer), {conf, jwks}, ttl: ttl(conf_exp, jwks_exp)) + {:ok, {conf, jwks}} + else + err -> + Logger.warning("failed to load oidc provider configuration for #{issuer}: #{inspect(err)}") + normalize(err) + end + end + + defp normalize({:error, _} = err), do: err + defp normalize(err), do: {:error, err} + + defp key(issuer), do: {:oidc_provider_config, issuer} + + defp ttl(conf_exp, jwks_exp) do + min(conf_exp, jwks_exp) + |> max(@min_ttl) + |> min(@max_ttl) + end +end diff --git a/lib/console/schema/chatbot_message.ex b/lib/console/schema/chatbot_message.ex index 026ea5b261..cc4dead6ad 100644 --- a/lib/console/schema/chatbot_message.ex +++ b/lib/console/schema/chatbot_message.ex @@ -8,6 +8,11 @@ defmodule Console.Schema.ChatbotMessage do field :external_id, :string field :external_parent_id, :string + # reply coordinates for providers that reply out-of-band (e.g. teams bot connector) + field :service_url, :string + field :conversation_id, :string + field :activity_id, :string + belongs_to :chat_connection, ChatConnection belongs_to :workbench_job, WorkbenchJob @@ -18,7 +23,7 @@ defmodule Console.Schema.ChatbotMessage do from(m in query, where: m.workbench_job_id == ^job_id) end - @valid ~w(message channel chat_connection_id workbench_job_id external_id external_parent_id)a + @valid ~w(message channel external_id external_parent_id service_url conversation_id activity_id chat_connection_id workbench_job_id)a def changeset(model, attrs \\ %{}) do model diff --git a/lib/console_web/controllers/webhook_controller.ex b/lib/console_web/controllers/webhook_controller.ex index 01cffee686..a9ba462434 100644 --- a/lib/console_web/controllers/webhook_controller.ex +++ b/lib/console_web/controllers/webhook_controller.ex @@ -1,7 +1,10 @@ defmodule ConsoleWeb.WebhookController do use ConsoleWeb, :controller - alias Console.Schema.{ScmWebhook, Cluster, ObservabilityWebhook, IssueWebhook} + alias Console.Repo + alias Console.Schema.{ScmWebhook, Cluster, ObservabilityWebhook, IssueWebhook, ChatConnection} alias Console.Deployments.{Git, Clusters, Observability, Integrations, Issues} + alias Console.Chat.Impl.Teams, as: TeamsBot + alias Console.Chat.Teams.Auth, as: TeamsAuth def cluster(conn, _) do with {:ok, _, token} <- ConsoleWeb.Plugs.Token.get_bearer_token(conn), @@ -40,6 +43,21 @@ defmodule ConsoleWeb.WebhookController do end end + def teams(conn, %{"id" => id}) do + activity = conn.body_params + + with %ChatConnection{type: :teams, configuration: %{teams: %{client_id: cid}}} = chat <- Repo.get(ChatConnection, id), + true <- is_binary(cid), + {:ok, _, token} <- ConsoleWeb.Plugs.Token.get_bearer_token(conn), + {:ok, _} <- TeamsAuth.verify(token, cid, service_url: activity["serviceUrl"]), + :ok <- TeamsBot.handle_activity(chat, activity) do + json(conn, %{ignored: false}) + else + {:error, _} -> send_resp(conn, 401, "Unauthorized") + _ -> json(conn, %{ignored: true}) + end + end + def issue(conn, %{"id" => id}) do with %IssueWebhook{} = hook <- Integrations.get_issue_webhook_by_ext_id(id), :ok <- verify(conn, hook), diff --git a/lib/console_web/router.ex b/lib/console_web/router.ex index 6b76e4d794..e3227824e4 100644 --- a/lib/console_web/router.ex +++ b/lib/console_web/router.ex @@ -57,6 +57,7 @@ defmodule ConsoleWeb.Router do get "/agent/chart", GitController, :agent_chart post "/webhooks/observability/:type/:id", WebhookController, :observability post "/webhooks/issues/:type/:id", WebhookController, :issue + post "/webhooks/teams/:id", WebhookController, :teams post "/webhooks/:type/:id", WebhookController, :scm scope "/states" do diff --git a/priv/prompts/workbench/chat.md.eex b/priv/prompts/workbench/chat.md.eex index 6aad520af4..909bbd3dd3 100644 --- a/priv/prompts/workbench/chat.md.eex +++ b/priv/prompts/workbench/chat.md.eex @@ -4,11 +4,14 @@ You've been mentioned in <%= @chat.type %> with the following message (id=<%= @m Do what you can to help the user with their request. -<%= if @behavior == :message do %> -You should respond in the channel once you've completed the job, and react to the original message to indicate you're working on it. -<% else %> -You should reply to the original message once you've completed the job (you have the message id to use above), and react to the original message to indicate you're working on it. All text responses should be threaded replies. -<% end %> Once done, add a final reaction to indicate completion. +<%= cond do %> +<% @chat.type == :teams -> %> +Respond in the conversation using the `teams_reply` tool once you've completed the job. You do not need channel or message ids - just call `teams_reply` with your response text. Microsoft Teams does not support bot-set emoji reactions, so do not attempt to react to messages. +<% @behavior == :message -> %> +You should respond in the channel once you've completed the job, and react to the original message to indicate you're working on it. Once done, add a final reaction to indicate completion. +<% true -> %> +You should reply to the original message once you've completed the job (you have the message id to use above), and react to the original message to indicate you're working on it. All text responses should be threaded replies. Once done, add a final reaction to indicate completion. +<% end %> <%= if is_binary(@custom) && byte_size(@custom) > 0 do %> diff --git a/priv/repo/migrations/20260715120000_add_chatbot_message_reply_context.exs b/priv/repo/migrations/20260715120000_add_chatbot_message_reply_context.exs new file mode 100644 index 0000000000..7e6a5bcd9b --- /dev/null +++ b/priv/repo/migrations/20260715120000_add_chatbot_message_reply_context.exs @@ -0,0 +1,11 @@ +defmodule Console.Repo.Migrations.AddChatbotMessageReplyContext do + use Ecto.Migration + + def change do + alter table(:chatbot_messages) do + add :service_url, :string + add :conversation_id, :string + add :activity_id, :string + end + end +end diff --git a/priv/tools/workbench/integration/teams/reply.json b/priv/tools/workbench/integration/teams/reply.json new file mode 100644 index 0000000000..8dfd2f123f --- /dev/null +++ b/priv/tools/workbench/integration/teams/reply.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "description": "Reply in the Teams conversation that triggered this chatbot job (via the Bot Framework connector).", + "properties": { + "text": { + "type": "string", + "description": "Reply body (plain text)." + } + }, + "required": ["text"], + "additionalProperties": false +} diff --git a/test/console/chat/impl/teams_test.exs b/test/console/chat/impl/teams_test.exs new file mode 100644 index 0000000000..cc52c47a93 --- /dev/null +++ b/test/console/chat/impl/teams_test.exs @@ -0,0 +1,82 @@ +defmodule Console.Chat.Impl.TeamsTest do + use Console.DataCase, async: true + alias Console.Chat.Impl.Teams + alias Console.Schema.ChatbotMessage + alias Console.Repo + + @bot_id "28:bot-app-id" + @channel_id "19:channel@thread.tacv2" + + defp activity(overrides \\ %{}) do + Map.merge( + %{ + "type" => "message", + "id" => "activity-1", + "serviceUrl" => "https://smba.trafficmanager.net/amer/", + "recipient" => %{"id" => @bot_id, "name" => "Console Bot"}, + "conversation" => %{"id" => "#{@channel_id};messageid=1"}, + "channelData" => %{"channel" => %{"id" => @channel_id}}, + "text" => "Console Bot please fix the deploy", + "entities" => [%{"type" => "mention", "mentioned" => %{"id" => @bot_id}}] + }, + overrides + ) + end + + defp teams_connection() do + insert(:chat_connection, + type: :teams, + configuration: %{teams: %{client_id: "cid", client_secret: "secret", tenant_id: "tid"}} + ) + end + + describe "handle_activity/2" do + test "spawns a workbench job and persists reply context when the bot is mentioned" do + user = insert(:user) + workbench = insert(:workbench, read_bindings: [%{user_id: user.id}]) + conn = teams_connection() + + insert(:workbench_chatbot, + workbench: workbench, + chat_connection: conn, + user: user, + channel: @channel_id, + prompt: "be helpful" + ) + + assert :ok = Teams.handle_activity(conn, activity()) + + assert [%ChatbotMessage{} = msg] = Repo.all(ChatbotMessage) + assert msg.chat_connection_id == conn.id + assert msg.channel == @channel_id + assert msg.message == "please fix the deploy" + assert msg.service_url == "https://smba.trafficmanager.net/amer/" + assert msg.conversation_id == "#{@channel_id};messageid=1" + assert msg.activity_id == "activity-1" + end + + test "ignores messages that do not mention the bot" do + conn = teams_connection() + + no_mention = activity(%{"entities" => [%{"type" => "mention", "mentioned" => %{"id" => "28:someone-else"}}]}) + + assert :ok = Teams.handle_activity(conn, no_mention) + + assert [] = Repo.all(ChatbotMessage) + end + + test "ignores non-message activity types" do + conn = teams_connection() + + assert :ok = Teams.handle_activity(conn, activity(%{"type" => "conversationUpdate"})) + assert [] = Repo.all(ChatbotMessage) + end + + test "does not create a job when no chatbot is bound to the channel" do + conn = teams_connection() + + assert :ok = Teams.handle_activity(conn, activity()) + assert [] = Repo.all(ChatbotMessage) + end + end +end diff --git a/test/console/chat/teams/auth_test.exs b/test/console/chat/teams/auth_test.exs new file mode 100644 index 0000000000..5f3a0aa619 --- /dev/null +++ b/test/console/chat/teams/auth_test.exs @@ -0,0 +1,15 @@ +defmodule Console.Chat.Teams.AuthTest do + use Console.DataCase, async: true + alias Console.Chat.Teams.Auth + + describe "verify/3" do + test "rejects a malformed token before any network calls" do + assert {:error, _} = Auth.verify("not-a-jwt", "some-app-id") + end + + test "rejects when token or audience are not binaries" do + assert {:error, _} = Auth.verify(nil, "aud") + assert {:error, _} = Auth.verify("token", nil) + end + end +end diff --git a/test/console_web/controllers/webhook_controller_test.exs b/test/console_web/controllers/webhook_controller_test.exs index 68465720f0..2dd9a7373a 100644 --- a/test/console_web/controllers/webhook_controller_test.exs +++ b/test/console_web/controllers/webhook_controller_test.exs @@ -18,6 +18,29 @@ defmodule ConsoleWeb.WebhookControllerTest do end end + describe "#teams/2" do + test "it ignores requests for unknown chat connections", %{conn: conn} do + conn + |> put_req_header("content-type", "application/json") + |> post("/ext/v1/webhooks/teams/#{Ecto.UUID.generate()}", Jason.encode!(%{"type" => "message"})) + |> json_response(200) + |> then(&assert(&1["ignored"])) + end + + test "it rejects requests without a valid bot framework token", %{conn: conn} do + chat = insert(:chat_connection, + type: :teams, + configuration: %{teams: %{client_id: "cid", client_secret: "secret", tenant_id: "tid"}} + ) + + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "Bearer not-a-real-jwt") + |> post("/ext/v1/webhooks/teams/#{chat.id}", Jason.encode!(%{"type" => "message"})) + |> response(401) + end + end + describe "#scm/2" do test "it returns 403 for azure devops webhook without valid basic auth", %{conn: conn} do hook = insert(:scm_webhook, type: :azure_devops)