defmodule Jido.Chat.Slack.Adapter do
@moduledoc """
Slack `Jido.Chat.Adapter` implementation using Slack Web API with webhook or
Socket Mode ingress.
"""
use Jido.Chat.Adapter
alias Jido.Chat.{
ChannelInfo,
EphemeralMessage,
EventEnvelope,
FileUpload,
Incoming,
Message,
MessagePage,
ModalResult,
Response,
SlashCommandEvent,
ThreadPage,
ThreadSummary,
WebhookRequest,
WebhookResponse
}
alias Jido.Chat.Slack.{
DeleteOptions,
EditOptions,
EphemeralOptions,
FetchOptions,
InteractionResponse,
MetadataOptions,
ModalOptions,
ReactionOptions,
SocketModeWorker,
SendOptions
}
alias Jido.Chat.Slack.Transport.ReqClient
@signature_prefix "v0="
@impl true
def channel_type, do: :slack
@impl true
@spec capabilities() :: map()
def capabilities,
do: %{
initialize: :fallback,
shutdown: :fallback,
send_message: :native,
send_file: :native,
edit_message: :native,
delete_message: :native,
start_typing: :unsupported,
fetch_metadata: :native,
fetch_thread: :native,
fetch_message: :native,
add_reaction: :native,
remove_reaction: :native,
post_ephemeral: :native,
open_dm: :native,
fetch_messages: :native,
fetch_channel_messages: :native,
list_threads: :native,
open_thread: :native,
post_channel_message: :fallback,
stream: :fallback,
open_modal: :native,
webhook: :native,
verify_webhook: :native,
parse_event: :native,
format_webhook_response: :native
}
@impl true
def listener_child_specs(bridge_id, opts \\ [])
when is_binary(bridge_id) and is_list(opts) do
ingress = normalize_ingress_opts(opts)
case ingress_mode(ingress) do
:webhook ->
{:ok, []}
:socket_mode ->
with {:ok, sink_mfa} <- validate_sink_mfa(Keyword.get(opts, :sink_mfa)) do
{:ok,
[
Supervisor.child_spec(
{SocketModeWorker, socket_mode_worker_opts(bridge_id, ingress, opts, sink_mfa)},
id: {:slack_socket_mode_worker, bridge_id}
)
]}
end
:invalid ->
{:error, :invalid_ingress_mode}
end
end
@impl true
def transform_incoming(payload) do
with {:ok, message_payload, metadata} <- normalize_message_payload(payload) do
channel_id = map_get(message_payload, [:channel, "channel"])
user_id = message_user_id(message_payload)
text = map_get(message_payload, [:text, "text"])
thread_ts = external_thread_id(message_payload)
chat_type = parse_chat_type(message_payload)
mentions = parse_mentions(text)
{:ok,
Incoming.new(%{
external_room_id: channel_id,
external_user_id: user_id,
text: text,
username: message_username(message_payload),
display_name: message_display_name(message_payload),
external_message_id: map_get(message_payload, [:ts, "ts"]),
external_reply_to_id: external_reply_to_id(message_payload),
external_thread_id: thread_ts,
timestamp: map_get(message_payload, [:ts, "ts"]),
chat_type: chat_type,
chat_title:
map_get(message_payload, [
:channel_name,
"channel_name",
:channel_title,
"channel_title"
]),
was_mentioned: metadata.was_mentioned,
mentions: mentions,
media: extract_media(message_payload),
channel_meta: %{
adapter_name: :slack,
external_room_id: channel_id,
external_thread_id: thread_ts,
chat_type: chat_type,
chat_title:
map_get(message_payload, [
:channel_name,
"channel_name",
:channel_title,
"channel_title"
]),
is_dm: chat_type == :dm,
metadata: %{
subtype: map_get(message_payload, [:subtype, "subtype"]),
team: map_get(message_payload, [:team, "team"]),
event_ts: map_get(message_payload, [:event_ts, "event_ts"])
}
},
raw: normalize_struct(message_payload),
metadata: Map.merge(metadata.extra, %{subtype: map_get(message_payload, [:subtype, "subtype"])})
})}
end
end
@impl true
def send_message(channel_id, text, opts \\ []) do
opts = SendOptions.new(opts)
with {:ok, result} <-
transport(opts).send_message(
channel_id,
text,
SendOptions.transport_opts(opts) ++ payload_to_opts(SendOptions.payload_opts(opts))
) do
{:ok,
Response.new(%{
external_message_id: map_get(result, [:ts, "ts"]),
external_room_id: map_get(result, [:channel, "channel"]) || channel_id,
timestamp: map_get(result, [:ts, "ts"]),
channel_type: :slack,
status: :sent,
raw: result,
metadata: %{message: map_get(result, [:message, "message"])}
})}
end
end
@impl true
def send_file(channel_id, file, opts \\ []) do
upload = FileUpload.normalize(file)
raw_opts = opts
send_opts = SendOptions.new(opts)
with :ok <- validate_upload(upload),
{:ok, result} <-
transport(send_opts).send_file(
channel_id,
upload,
SendOptions.transport_opts(send_opts) ++
file_transport_opts(raw_opts, send_opts, upload)
) do
{:ok, upload_response(channel_id, upload, result)}
end
end
@impl true
def edit_message(channel_id, message_id, text, opts \\ []) do
opts = EditOptions.new(opts)
with {:ok, result} <-
transport(opts).edit_message(
channel_id,
message_id,
text,
EditOptions.transport_opts(opts) ++ payload_to_opts(EditOptions.payload_opts(opts))
) do
{:ok,
Response.new(%{
external_message_id: map_get(result, [:ts, "ts"]) || stringify(message_id),
external_room_id: map_get(result, [:channel, "channel"]) || channel_id,
timestamp: map_get(result, [:ts, "ts"]),
channel_type: :slack,
status: :edited,
raw: result,
metadata: %{message: map_get(result, [:message, "message"])}
})}
end
end
@impl true
def delete_message(channel_id, message_id, opts \\ []) do
opts = opts |> pick_opts([:token, :transport, :req]) |> DeleteOptions.new()
with {:ok, _result} <-
transport(opts).delete_message(
channel_id,
message_id,
DeleteOptions.transport_opts(opts)
) do
:ok
end
end
@impl true
def start_typing(_channel_id, _opts \\ []), do: {:error, :unsupported}
@impl true
def fetch_metadata(channel_id, opts \\ []) do
opts =
opts |> pick_opts([:token, :transport, :req, :include_num_members]) |> MetadataOptions.new()
with {:ok, result} <-
transport(opts).fetch_metadata(channel_id, MetadataOptions.transport_opts(opts)) do
metadata = normalize_struct(result)
{:ok,
ChannelInfo.new(%{
id: stringify(map_get(metadata, [:id, "id"]) || channel_id),
name:
map_get(metadata, [:name, "name"]) ||
map_get(metadata, [:user, "user"]) |> maybe_get([:name, "name"]),
is_dm: conversation_is_dm?(metadata, channel_id),
member_count: map_get(metadata, [:num_members, "num_members"]),
metadata: metadata
})}
end
end
@impl true
def fetch_thread(channel_id, opts \\ []) do
external_thread_id =
opts[:external_thread_id] || opts[:thread_ts] || opts[:message_id]
if is_nil(external_thread_id) do
with {:ok, info} <- fetch_metadata(channel_id, opts) do
{:ok,
%{
id: "slack:#{channel_id}",
adapter_name: :slack,
external_room_id: channel_id,
channel_id: "slack:#{channel_id}",
is_dm: info.is_dm || false,
metadata: info.metadata
}}
end
else
with {:ok, info} <- fetch_metadata(channel_id, opts),
{:ok, root_message} <-
fetch_message(
channel_id,
external_thread_id,
Keyword.put(opts, :thread_ts, external_thread_id)
) do
{:ok,
%{
id: thread_id(channel_id, external_thread_id),
adapter_name: :slack,
external_room_id: channel_id,
external_thread_id: stringify(external_thread_id),
channel_id: "slack:#{channel_id}",
is_dm: info.is_dm || false,
metadata: Map.merge(info.metadata, %{root_message: root_message})
}}
end
end
end
@impl true
def fetch_message(channel_id, message_id, opts \\ []) do
transport_opts =
opts
|> pick_opts([:token, :transport, :req, :thread_ts, :external_thread_id])
with {:ok, raw_message} <-
transport(transport_opts).fetch_message(channel_id, message_id, transport_opts),
{:ok, incoming} <- transform_incoming(ensure_channel_id(raw_message, channel_id)) do
{:ok,
Message.from_incoming(incoming,
adapter_name: :slack,
thread_id: thread_id(incoming.external_room_id, incoming.external_thread_id)
)}
end
end
@impl true
def open_dm(user_id, opts \\ []) do
transport_opts = pick_opts(opts, [:token, :transport, :req])
transport(transport_opts).open_dm(user_id, transport_opts)
end
@impl true
def open_thread(channel_id, message_id, opts \\ []) do
thread_ts = opts[:thread_ts] || opts[:external_thread_id] || message_id
{:ok,
%{
external_thread_id: stringify(thread_ts),
delivery_external_room_id: stringify(channel_id)
}}
end
@impl true
def post_ephemeral(channel_id, user_id, text, opts \\ []) do
opts = EphemeralOptions.new(opts)
with {:ok, result} <-
transport(opts).post_ephemeral(
channel_id,
user_id,
text,
EphemeralOptions.transport_opts(opts) ++
payload_to_opts(EphemeralOptions.payload_opts(opts))
) do
thread_ts = map_get(result, [:message_ts, "message_ts"]) || opts.thread_ts
{:ok,
EphemeralMessage.new(%{
id:
"slack:ephemeral:" <>
(map_get(result, [:message_ts, "message_ts"]) || Jido.Chat.ID.generate!()),
thread_id: thread_id(channel_id, thread_ts),
used_fallback: false,
raw: result,
metadata: %{channel_id: stringify(channel_id), user_id: stringify(user_id)}
})}
end
end
@impl true
def open_modal(channel_id, payload, opts \\ []) when is_map(payload) do
opts = opts |> pick_opts([:token, :transport, :req, :trigger_id]) |> ModalOptions.new()
if is_nil(opts.trigger_id) do
{:error, :missing_trigger_id}
else
with {:ok, result} <-
transport(opts).open_modal(
opts.trigger_id,
payload,
ModalOptions.transport_opts(opts)
) do
view = map_get(result, [:view, "view"]) || %{}
{:ok,
ModalResult.new(%{
id:
"slack:modal:" <>
(map_get(view, [:id, "id"]) || map_get(result, [:view_id, "view_id"]) ||
Jido.Chat.ID.generate!()),
status: :opened,
external_room_id: channel_id,
raw: result,
metadata: %{
trigger_id: opts.trigger_id,
view_id: map_get(view, [:id, "id"]),
callback_id: map_get(view, [:callback_id, "callback_id"])
}
})}
end
end
end
@impl true
def add_reaction(channel_id, message_id, emoji, opts \\ []) do
opts = opts |> pick_opts([:token, :transport, :req]) |> ReactionOptions.new()
with {:ok, _result} <-
transport(opts).add_reaction(
channel_id,
message_id,
emoji,
ReactionOptions.transport_opts(opts)
) do
:ok
end
end
@impl true
def remove_reaction(channel_id, message_id, emoji, opts \\ []) do
opts = opts |> pick_opts([:token, :transport, :req]) |> ReactionOptions.new()
with {:ok, _result} <-
transport(opts).remove_reaction(
channel_id,
message_id,
emoji,
ReactionOptions.transport_opts(opts)
) do
:ok
end
end
@impl true
def fetch_messages(channel_id, opts \\ []) do
opts =
opts
|> pick_opts([
:token,
:transport,
:req,
:cursor,
:limit,
:direction,
:thread_ts,
:inclusive,
:external_thread_id
])
|> maybe_put_thread_ts()
|> FetchOptions.new()
with :ok <- validate_history_direction(opts.direction),
{:ok, result} <-
transport(opts).fetch_messages(channel_id, FetchOptions.transport_opts(opts)) do
thread_ts = opts.thread_ts
{:ok,
MessagePage.new(%{
messages:
normalize_messages(
map_get(result, [:messages, "messages"]) || [],
channel_id,
thread_ts
),
next_cursor: next_cursor(result),
direction: opts.direction,
metadata: %{raw: result}
})}
end
end
@impl true
def fetch_channel_messages(channel_id, opts \\ []) do
opts =
opts
|> Keyword.drop([:thread_ts, :external_thread_id])
fetch_messages(channel_id, opts)
end
@impl true
def list_threads(channel_id, opts \\ []) do
fetch_opts =
opts
|> pick_opts([:token, :transport, :req, :cursor, :limit, :direction])
|> FetchOptions.new()
with :ok <- validate_history_direction(fetch_opts.direction),
{:ok, result} <-
transport(fetch_opts).list_threads(channel_id, FetchOptions.transport_opts(fetch_opts)) do
threads =
result
|> map_get([:threads, "threads"])
|> List.wrap()
|> Enum.flat_map(&normalize_thread_summary(channel_id, &1))
{:ok,
ThreadPage.new(%{
threads: threads,
next_cursor: next_cursor(result),
metadata: %{raw: result}
})}
end
end
@impl true
def verify_webhook(%WebhookRequest{} = request, opts \\ []) do
signing_secret =
opts[:signing_secret] || opts[:slack_signing_secret] ||
Application.get_env(:jido_chat_slack, :slack_signing_secret)
if is_nil(signing_secret) do
:ok
else
verify_slack_signature(request, signing_secret, opts)
end
end
@impl true
def parse_event(%WebhookRequest{} = request, _opts \\ []) do
with {:ok, payload} <- decode_request_payload(request) do
parse_payload_event(payload, request)
end
end
@impl true
def format_webhook_response(result, opts \\ [])
def format_webhook_response({:ok, _chat, :noop}, opts) do
request = opts[:request]
payload = request && request_payload(request)
if url_verification_payload?(payload) do
WebhookResponse.new(%{
status: 200,
headers: %{"content-type" => "text/plain"},
body: map_get(payload, [:challenge, "challenge"])
})
else
WebhookResponse.new(%{
status: 200,
headers: %{"content-type" => "text/plain"},
body: ""
})
end
end
def format_webhook_response({:ok, _chat, _event} = result, opts) do
case InteractionResponse.webhook_response(result, opts) do
%WebhookResponse{} = response ->
response
nil ->
WebhookResponse.new(%{
status: 200,
headers: %{"content-type" => "text/plain"},
body: ""
})
end
end
def format_webhook_response({:error, reason}, _opts)
when reason in [
:invalid_signature,
:stale_timestamp,
:missing_signature,
:missing_timestamp
] do
WebhookResponse.error(401, %{error: to_string(reason)})
end
def format_webhook_response({:error, :missing_raw_body}, _opts) do
WebhookResponse.error(400, %{error: "missing_raw_body"})
end
def format_webhook_response({:error, reason}, _opts) do
WebhookResponse.error(400, %{error: inspect(reason)})
end
@impl true
def handle_webhook(%Jido.Chat{} = chat, payload, opts \\ []) when is_map(payload) do
request =
WebhookRequest.new(%{
adapter_name: :slack,
headers: opts[:headers] || %{},
payload: payload,
raw: opts[:raw_body] || payload,
metadata: %{raw_body: opts[:raw_body]}
})
with :ok <- verify_webhook(request, opts),
{:ok, parsed_event} <- parse_event(request, opts),
{:ok, updated_chat, incoming} <- route_parsed_event(chat, parsed_event, opts, request) do
{:ok, updated_chat, incoming}
end
end
defp route_parsed_event(chat, :noop, _opts, %WebhookRequest{} = request) do
{:ok, chat, synthetic_incoming("slack", nil, nil, request_payload(request), :noop)}
end
defp route_parsed_event(chat, %EventEnvelope{} = envelope, opts, _request) do
with {:ok, updated_chat, routed_envelope} <-
Jido.Chat.process_event(chat, :slack, envelope, opts),
{:ok, incoming} <- incoming_from_event(routed_envelope) do
{:ok, updated_chat, incoming}
end
end
defp incoming_from_event(%EventEnvelope{event_type: :message, payload: %Incoming{} = incoming}),
do: {:ok, incoming}
defp incoming_from_event(%EventEnvelope{event_type: :slash_command, raw: raw}) do
{:ok,
synthetic_incoming(
map_get(raw, [:channel_id, "channel_id"]) || "slack",
map_get(raw, [:user_id, "user_id"]),
map_get(raw, [:trigger_id, "trigger_id"]),
raw,
:slash_command
)}
end
defp incoming_from_event(%EventEnvelope{event_type: :action, raw: raw}) do
channel_id =
map_get(raw, [:channel, "channel"])
|> maybe_get([:id, "id"])
user_id = map_get(raw, [:user, "user"]) |> maybe_get([:id, "id"])
message_id = map_get(raw, [:container, "container"]) |> maybe_get([:message_ts, "message_ts"])
{:ok, synthetic_incoming(channel_id || "slack", user_id, message_id, raw, :action)}
end
defp incoming_from_event(%EventEnvelope{event_type: :modal_submit, raw: raw}) do
metadata =
decode_private_metadata(
map_get(raw, [:view, "view"])
|> maybe_get([:private_metadata, "private_metadata"])
)
{:ok,
synthetic_incoming(
Map.get(metadata, "channel_id") || "slack",
map_get(raw, [:user, "user"]) |> maybe_get([:id, "id"]),
map_get(raw, [:view, "view"]) |> maybe_get([:id, "id"]),
raw,
:modal_submit
)}
end
defp incoming_from_event(%EventEnvelope{event_type: :modal_close, raw: raw}) do
metadata =
decode_private_metadata(
map_get(raw, [:view, "view"])
|> maybe_get([:private_metadata, "private_metadata"])
)
{:ok,
synthetic_incoming(
Map.get(metadata, "channel_id") || "slack",
map_get(raw, [:user, "user"]) |> maybe_get([:id, "id"]),
map_get(raw, [:view, "view"]) |> maybe_get([:id, "id"]),
raw,
:modal_close
)}
end
defp incoming_from_event(%EventEnvelope{event_type: :reaction, raw: raw}) do
item = map_get(raw, [:item, "item"]) || %{}
{:ok,
synthetic_incoming(
map_get(item, [:channel, "channel"]) || "slack",
map_get(raw, [:user, "user"]),
map_get(item, [:ts, "ts"]),
raw,
:reaction
)}
end
defp incoming_from_event(_), do: {:error, :unsupported_event_type}
defp parse_payload_event(payload, request) when is_map(payload) do
cond do
url_verification_payload?(payload) ->
{:ok, :noop}
slash_command_payload?(payload) ->
{:ok, slash_command_envelope(payload)}
interactive_payload?(payload) ->
interactive_event_envelope(payload)
event_callback_payload?(payload) ->
event_callback_envelope(payload, request)
message_like_payload?(payload) ->
with {:ok, incoming} <- transform_incoming(payload) do
{:ok,
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :message,
thread_id: thread_id(incoming.external_room_id, incoming.external_thread_id),
channel_id: stringify(incoming.external_room_id),
message_id: stringify(incoming.external_message_id),
payload: incoming,
raw: payload,
metadata: %{source: :direct}
})}
end
true ->
{:ok, :noop}
end
end
defp parse_payload_event(_payload, _request), do: {:ok, :noop}
defp event_callback_envelope(payload, request) do
event = map_get(payload, [:event, "event"]) || %{}
case normalize_event_type(map_get(event, [:type, "type"])) do
:message ->
message_payload = extract_event_message(event, payload)
if is_nil(message_payload) do
{:ok, :noop}
else
with {:ok, incoming} <- transform_incoming(message_payload) do
{:ok,
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :message,
thread_id: thread_id(incoming.external_room_id, incoming.external_thread_id),
channel_id: stringify(incoming.external_room_id),
message_id: stringify(incoming.external_message_id),
payload: incoming,
raw: payload,
metadata: %{
event_id: map_get(payload, [:event_id, "event_id"]),
path: request.path
}
})}
end
end
:app_mention ->
with {:ok, incoming} <- transform_incoming(Map.put(event, "type", "app_mention")) do
{:ok,
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :message,
thread_id: thread_id(incoming.external_room_id, incoming.external_thread_id),
channel_id: stringify(incoming.external_room_id),
message_id: stringify(incoming.external_message_id),
payload: incoming,
raw: payload,
metadata: %{event_id: map_get(payload, [:event_id, "event_id"])}
})}
end
:reaction_added ->
{:ok, reaction_envelope(payload, true)}
:reaction_removed ->
{:ok, reaction_envelope(payload, false)}
_ ->
{:ok, :noop}
end
end
defp slash_command_envelope(payload) do
channel_id = map_get(payload, [:channel_id, "channel_id"])
user =
author_from_user_payload(%{
"id" => map_get(payload, [:user_id, "user_id"]),
"username" => map_get(payload, [:user_name, "user_name"])
})
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :slash_command,
thread_id: thread_id(channel_id, nil),
channel_id: stringify(channel_id),
message_id: stringify(map_get(payload, [:trigger_id, "trigger_id"])),
payload:
SlashCommandEvent.new(%{
adapter_name: :slack,
thread_id: thread_id(channel_id, nil),
channel_id: stringify(channel_id),
message_id: stringify(map_get(payload, [:trigger_id, "trigger_id"])),
command: map_get(payload, [:command, "command"]),
text: map_get(payload, [:text, "text"]) || "",
trigger_id: stringify(map_get(payload, [:trigger_id, "trigger_id"])),
user: user,
raw: payload,
metadata: %{
response_url: map_get(payload, [:response_url, "response_url"]),
team_id: map_get(payload, [:team_id, "team_id"])
}
}),
raw: payload,
metadata: %{}
})
end
defp interactive_event_envelope(payload) do
case map_get(payload, [:type, "type"]) do
"block_actions" ->
{:ok, action_envelope(payload)}
"shortcut" ->
{:ok, action_envelope(payload)}
"message_action" ->
{:ok, action_envelope(payload)}
"view_submission" ->
{:ok, modal_submit_envelope(payload)}
"view_closed" ->
{:ok, modal_close_envelope(payload)}
_ ->
{:ok, :noop}
end
end
defp action_envelope(payload) do
action = first_action(payload)
channel_id = map_get(payload, [:channel, "channel"]) |> maybe_get([:id, "id"])
message_id =
map_get(payload, [:container, "container"]) |> maybe_get([:message_ts, "message_ts"])
message = map_get(payload, [:message, "message"]) || %{}
thread_ts = external_thread_id(message)
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :action,
thread_id: thread_id(channel_id, thread_ts),
channel_id: stringify(channel_id),
message_id: stringify(message_id || map_get(message, [:ts, "ts"])),
payload: %{
adapter_name: :slack,
thread_id: thread_id(channel_id, thread_ts),
message_id: stringify(message_id || map_get(message, [:ts, "ts"])),
action_id:
map_get(action, [:action_id, "action_id"]) ||
map_get(payload, [:callback_id, "callback_id"]),
value: extract_action_value(action),
trigger_id: map_get(payload, [:trigger_id, "trigger_id"]),
user: author_from_user_payload(map_get(payload, [:user, "user"]) || %{}),
raw: payload,
metadata: %{
response_url: map_get(payload, [:response_url, "response_url"]),
channel_id: channel_id
}
},
raw: payload,
metadata: %{}
})
end
defp modal_submit_envelope(payload) do
view = map_get(payload, [:view, "view"]) || %{}
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :modal_submit,
thread_id: nil,
channel_id: nil,
message_id: stringify(map_get(view, [:id, "id"])),
payload: %{
adapter_name: :slack,
callback_id: map_get(view, [:callback_id, "callback_id"]),
view_id: map_get(view, [:id, "id"]),
values: extract_modal_values(view),
user: author_from_user_payload(map_get(payload, [:user, "user"]) || %{}),
raw: payload,
metadata: %{
private_metadata: map_get(view, [:private_metadata, "private_metadata"]),
hash: map_get(view, [:hash, "hash"])
}
},
raw: payload,
metadata: %{}
})
end
defp modal_close_envelope(payload) do
view = map_get(payload, [:view, "view"]) || %{}
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :modal_close,
thread_id: nil,
channel_id: nil,
message_id: stringify(map_get(view, [:id, "id"])),
payload: %{
adapter_name: :slack,
callback_id: map_get(view, [:callback_id, "callback_id"]),
view_id: map_get(view, [:id, "id"]),
user: author_from_user_payload(map_get(payload, [:user, "user"]) || %{}),
raw: payload,
metadata: %{
private_metadata: map_get(view, [:private_metadata, "private_metadata"]),
hash: map_get(view, [:hash, "hash"])
}
},
raw: payload,
metadata: %{}
})
end
defp reaction_envelope(payload, added?) do
event = map_get(payload, [:event, "event"]) || %{}
item = map_get(event, [:item, "item"]) || %{}
channel_id = map_get(item, [:channel, "channel"])
ts = map_get(item, [:ts, "ts"])
EventEnvelope.new(%{
adapter_name: :slack,
event_type: :reaction,
thread_id: thread_id(channel_id, ts),
channel_id: stringify(channel_id),
message_id: stringify(ts),
payload: %{
adapter_name: :slack,
thread_id: thread_id(channel_id, ts),
message_id: stringify(ts),
emoji: map_get(event, [:reaction, "reaction"]),
added: added?,
user: author_from_user_payload(%{"id" => map_get(event, [:user, "user"])}),
raw: event,
metadata: %{item: item}
},
raw: payload,
metadata: %{}
})
end
defp verify_slack_signature(request, signing_secret, opts) do
raw_body = raw_body(request, opts)
if is_nil(raw_body) do
{:error, :missing_raw_body}
else
timestamp = header_value(request.headers, "x-slack-request-timestamp")
signature = header_value(request.headers, "x-slack-signature")
cond do
is_nil(timestamp) -> {:error, :missing_timestamp}
is_nil(signature) -> {:error, :missing_signature}
stale_timestamp?(timestamp, opts) -> {:error, :stale_timestamp}
secure_compare(signature, slack_signature(signing_secret, timestamp, raw_body)) -> :ok
true -> {:error, :invalid_signature}
end
end
end
defp decode_request_payload(%WebhookRequest{} = request) do
request.payload
|> decode_payload_source()
|> case do
{:ok, payload} -> {:ok, payload}
{:error, _reason} -> decode_payload_source(request.raw)
end
end
defp decode_payload_source(%{} = payload) do
case map_get(payload, [:payload, "payload"]) do
json when is_binary(json) -> Jason.decode(json)
_ -> {:ok, normalize_struct(payload)}
end
end
defp decode_payload_source(binary) when is_binary(binary) do
with {:error, _reason} <- Jason.decode(binary) do
decoded = URI.decode_query(binary)
case Map.get(decoded, "payload") do
nil -> {:ok, decoded}
json -> Jason.decode(json)
end
end
end
defp decode_payload_source(_), do: {:error, :unsupported_payload}
defp message_like_payload?(payload) when is_map(payload) do
normalize_event_type(map_get(payload, [:type, "type"])) in [:message, :app_mention]
end
defp slash_command_payload?(payload) when is_map(payload) do
is_binary(map_get(payload, [:command, "command"])) and
is_binary(map_get(payload, [:user_id, "user_id"]))
end
defp interactive_payload?(payload) when is_map(payload) do
map_get(payload, [:type, "type"]) in [
"block_actions",
"shortcut",
"message_action",
"view_submission",
"view_closed"
]
end
defp event_callback_payload?(payload) when is_map(payload) do
map_get(payload, [:type, "type"]) == "event_callback" and
is_map(map_get(payload, [:event, "event"]))
end
defp url_verification_payload?(payload) when is_map(payload) do
map_get(payload, [:type, "type"]) == "url_verification"
end
defp normalize_message_payload(payload) when is_map(payload) do
cond do
normalize_event_type(map_get(payload, [:type, "type"])) in [:message, :app_mention] ->
with message when is_map(message) <- unwrap_message_event(payload) do
{:ok, message,
%{
was_mentioned: normalize_event_type(map_get(payload, [:type, "type"])) == :app_mention,
extra: %{}
}}
else
nil -> {:error, :unsupported_message_type}
end
event_callback_payload?(payload) ->
event = map_get(payload, [:event, "event"]) || %{}
case normalize_event_type(map_get(event, [:type, "type"])) do
type when type in [:message, :app_mention] ->
case extract_event_message(event, payload) do
nil ->
{:error, :unsupported_message_type}
message ->
{:ok, message,
%{
was_mentioned: type == :app_mention,
extra: %{event_id: map_get(payload, [:event_id, "event_id"])}
}}
end
_ ->
{:error, :unsupported_message_type}
end
true ->
{:error, :unsupported_message_type}
end
end
defp normalize_message_payload(_), do: {:error, :unsupported_message_type}
defp extract_event_message(event, _payload) do
case map_get(event, [:subtype, "subtype"]) do
"message_changed" ->
map_get(event, [:message, "message"])
"message_deleted" ->
nil
_ ->
event
end
end
defp unwrap_message_event(payload) do
case map_get(payload, [:subtype, "subtype"]) do
"message_changed" -> map_get(payload, [:message, "message"])
"message_deleted" -> nil
_ -> payload
end
end
defp extract_media(message) when is_map(message) do
message
|> map_get([:files, "files"])
|> List.wrap()
|> Enum.map(&normalize_media/1)
|> Enum.reject(&is_nil/1)
end
defp normalize_media(file) when is_map(file) do
url =
map_get(file, [:url_private, "url_private"]) ||
map_get(file, [:url_private_download, "url_private_download"])
if is_nil(url) do
nil
else
%{
kind: media_kind(file),
url: url,
media_type: map_get(file, [:mimetype, "mimetype"]),
metadata: %{
id: map_get(file, [:id, "id"]),
name: map_get(file, [:name, "name"]),
size: map_get(file, [:size, "size"])
}
}
end
end
defp media_kind(file) do
case map_get(file, [:mimetype, "mimetype"]) do
"image/" <> _rest -> :image
"video/" <> _rest -> :video
"audio/" <> _rest -> :audio
_ -> :file
end
end
defp parse_mentions(text) when is_binary(text) do
Regex.scan(~r/<@([A-Z0-9]+)>/, text, return: :index)
|> Enum.map(fn
[{offset, length}, {user_offset, user_length}] ->
%{
user_id: binary_part(text, user_offset, user_length),
username: nil,
offset: offset,
length: length
}
_ ->
nil
end)
|> Enum.reject(&is_nil/1)
end
defp parse_mentions(_), do: []
defp normalize_messages(messages, channel_id, thread_ts) do
Enum.flat_map(messages, fn raw ->
case transform_incoming(raw) do
{:ok, incoming} ->
[
Message.from_incoming(incoming,
adapter_name: :slack,
thread_id: thread_id(channel_id, thread_ts || incoming.external_thread_id)
)
]
{:error, _reason} ->
[]
end
end)
end
defp ensure_channel_id(%{} = raw_message, channel_id) do
if is_nil(map_get(raw_message, [:channel, "channel"])) do
Map.put(raw_message, :channel, stringify(channel_id))
else
raw_message
end
end
defp ensure_channel_id(raw_message, _channel_id), do: raw_message
defp validate_upload(%FileUpload{path: path}) when is_binary(path) and path != "", do: :ok
defp validate_upload(%FileUpload{data: data, filename: filename})
when is_binary(data) and data != "" and is_binary(filename) and filename != "" do
:ok
end
defp validate_upload(%FileUpload{data: data}) when is_binary(data) and data != "" do
{:error, :missing_filename}
end
defp validate_upload(%FileUpload{url: url}) when is_binary(url) and url != "" do
{:error, :unsupported_remote_url}
end
defp validate_upload(_upload), do: {:error, :missing_file_source}
defp file_transport_opts(raw_opts, %SendOptions{} = opts, %FileUpload{} = upload) do
[]
|> maybe_put_kw(:thread_ts, opts.thread_ts)
|> maybe_put_kw(:initial_comment, upload_initial_comment(raw_opts, upload))
end
defp upload_initial_comment(raw_opts, %FileUpload{} = upload) do
metadata = upload.metadata
Keyword.get(raw_opts, :initial_comment) ||
Keyword.get(raw_opts, :caption) ||
Keyword.get(raw_opts, :text) ||
map_get(metadata, [:caption, "caption"]) ||
map_get(metadata, [:transcript, "transcript"])
end
defp upload_response(channel_id, %FileUpload{} = upload, result) do
file =
result
|> map_get([:files, "files"])
|> List.wrap()
|> List.first()
|> normalize_struct()
Response.new(%{
external_message_id: slack_file_share_ts(file, channel_id),
external_room_id: stringify(channel_id),
timestamp: slack_file_share_ts(file, channel_id),
channel_type: :slack,
status: :sent,
raw: result,
metadata:
%{
file_id: map_get(file, [:id, "id"]),
filename: map_get(file, [:name, "name"]),
size: map_get(file, [:size, "size"]),
url: map_get(file, [:url_private, "url_private"]),
content_type: map_get(file, [:mimetype, "mimetype"]),
upload_kind: upload.kind,
delivered_kind: slack_file_kind(file, upload.kind)
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
})
end
defp slack_file_share_ts(file, channel_id) when is_map(file) do
shares = map_get(file, [:shares, "shares"]) |> normalize_struct()
channel_id = stringify(channel_id)
[
maybe_get(shares, [:public, "public"]),
maybe_get(shares, [:private, "private"])
]
|> Enum.find_value(fn share_group ->
share_group
|> normalize_struct()
|> Map.get(channel_id, [])
|> List.wrap()
|> List.first()
|> maybe_get([:ts, "ts"])
end)
end
defp slack_file_share_ts(_file, _channel_id), do: nil
defp slack_file_kind(file, fallback) do
case map_get(file, [:mimetype, "mimetype"]) do
<<"image/", _::binary>> -> :image
<<"audio/", _::binary>> -> :audio
<<"video/", _::binary>> -> :video
_ -> fallback
end
end
defp maybe_put_kw(keyword, _key, nil), do: keyword
defp maybe_put_kw(keyword, key, value), do: Keyword.put(keyword, key, value)
defp normalize_thread_summary(channel_id, raw_thread) when is_map(raw_thread) do
case transform_incoming(raw_thread) do
{:ok, incoming} ->
[
ThreadSummary.new(%{
id:
thread_id(
channel_id,
external_thread_id(raw_thread) || incoming.external_message_id
),
last_reply_at: map_get(raw_thread, [:latest_reply, "latest_reply"]),
reply_count: map_get(raw_thread, [:reply_count, "reply_count"]) || 0,
root_message:
Message.from_incoming(incoming,
adapter_name: :slack,
thread_id:
thread_id(
channel_id,
incoming.external_thread_id || incoming.external_message_id
)
),
metadata: %{raw: raw_thread}
})
]
{:error, _reason} ->
[]
end
end
defp normalize_thread_summary(_channel_id, _raw_thread), do: []
defp external_thread_id(message) when is_map(message) do
thread_ts = map_get(message, [:thread_ts, "thread_ts"])
ts = map_get(message, [:ts, "ts"])
reply_count = map_get(message, [:reply_count, "reply_count"]) || 0
cond do
is_binary(thread_ts) -> thread_ts
reply_count > 0 -> ts
true -> nil
end
end
defp external_reply_to_id(message) when is_map(message) do
thread_ts = map_get(message, [:thread_ts, "thread_ts"])
ts = map_get(message, [:ts, "ts"])
if is_binary(thread_ts) and thread_ts != ts, do: thread_ts, else: nil
end
defp message_user_id(message) when is_map(message) do
map_get(message, [:user, "user"]) ||
bot_id(message)
end
defp message_username(message) when is_map(message) do
map_get(message, [:username, "username"]) ||
map_get(message, [:user_profile, "user_profile"])
|> maybe_get([:display_name, "display_name"])
end
defp message_display_name(message) when is_map(message) do
message_username(message) ||
map_get(message, [:user_profile, "user_profile"]) |> maybe_get([:real_name, "real_name"])
end
defp bot_id(message) do
case map_get(message, [:bot_id, "bot_id"]) do
nil -> nil
bot_id -> "bot:" <> to_string(bot_id)
end
end
defp parse_chat_type(message) do
case map_get(message, [:channel_type, "channel_type"]) do
"im" -> :dm
"mpim" -> :group
"group" -> :group
"channel" -> :channel
_ -> infer_chat_type_from_channel_id(map_get(message, [:channel, "channel"]))
end
end
defp infer_chat_type_from_channel_id("D" <> _rest), do: :dm
defp infer_chat_type_from_channel_id("G" <> _rest), do: :group
defp infer_chat_type_from_channel_id("C" <> _rest), do: :channel
defp infer_chat_type_from_channel_id(_), do: :channel
defp conversation_is_dm?(metadata, channel_id) do
map_get(metadata, [:is_im, "is_im"]) ||
infer_chat_type_from_channel_id(stringify(channel_id)) == :dm
end
defp validate_history_direction(:backward), do: :ok
defp validate_history_direction(_direction), do: {:error, :unsupported_direction}
defp author_from_user_payload(payload) when is_map(payload) do
%{
user_id: stringify(map_get(payload, [:id, "id"]) || "unknown"),
user_name:
map_get(payload, [:username, "username"]) ||
map_get(payload, [:name, "name"]) ||
stringify(map_get(payload, [:id, "id"]) || "unknown"),
full_name:
map_get(payload, [:real_name, "real_name"]) ||
map_get(payload, [:username, "username"]) ||
map_get(payload, [:name, "name"])
}
end
defp extract_action_value(action) when is_map(action) do
map_get(action, [:value, "value"]) ||
map_get(action, [:selected_option, "selected_option"]) |> maybe_get([:value, "value"]) ||
map_get(action, [:selected_user, "selected_user"]) ||
map_get(action, [:selected_channel, "selected_channel"]) ||
map_get(action, [:selected_conversation, "selected_conversation"])
end
defp extract_action_value(_), do: nil
defp extract_modal_values(view) when is_map(view) do
view
|> map_get([:state, "state"])
|> maybe_get([:values, "values"])
|> normalize_struct()
end
defp extract_modal_values(_), do: %{}
defp first_action(payload) do
payload
|> map_get([:actions, "actions"])
|> List.wrap()
|> List.first()
|> normalize_struct()
end
defp decode_private_metadata(nil), do: %{}
defp decode_private_metadata(value) when is_binary(value) do
case Jason.decode(value) do
{:ok, metadata} when is_map(metadata) -> metadata
_ -> %{}
end
end
defp decode_private_metadata(_), do: %{}
defp request_payload(%WebhookRequest{} = request) do
case decode_request_payload(request) do
{:ok, payload} -> payload
_ -> %{}
end
end
defp raw_body(request, opts) do
cond do
is_binary(opts[:raw_body]) -> opts[:raw_body]
is_binary(request.metadata[:raw_body]) -> request.metadata[:raw_body]
is_binary(request.raw) -> request.raw
true -> nil
end
end
defp stale_timestamp?(timestamp, opts) do
with {ts, ""} <- Integer.parse(to_string(timestamp)) do
now = opts[:now] || System.os_time(:second)
abs(now - ts) > 300
else
_ -> true
end
end
defp slack_signature(signing_secret, timestamp, raw_body) do
("v0:" <> to_string(timestamp) <> ":" <> raw_body)
|> then(&:crypto.mac(:hmac, :sha256, signing_secret, &1))
|> Base.encode16(case: :lower)
|> then(&(@signature_prefix <> &1))
end
defp secure_compare(left, right) when is_binary(left) and is_binary(right) do
if byte_size(left) == byte_size(right) do
left
|> :binary.bin_to_list()
|> Enum.zip(:binary.bin_to_list(right))
|> Enum.reduce(0, fn {a, b}, acc ->
Bitwise.bor(acc, Bitwise.bxor(a, b))
end)
|> Kernel.==(0)
else
false
end
end
defp payload_to_opts(payload) when is_map(payload) do
Enum.map(payload, fn
{"blocks", value} -> {:blocks, value}
{"attachments", value} -> {:attachments, value}
{"thread_ts", value} -> {:thread_ts, value}
{"reply_broadcast", value} -> {:reply_broadcast, value}
{"unfurl_links", value} -> {:unfurl_links, value}
{"unfurl_media", value} -> {:unfurl_media, value}
{"username", value} -> {:username, value}
{"icon_emoji", value} -> {:icon_emoji, value}
{"icon_url", value} -> {:icon_url, value}
{key, value} when is_atom(key) -> {key, value}
end)
end
defp header_value(headers, header_name) when is_map(headers) do
target = String.downcase(header_name)
Enum.find_value(headers, fn {key, value} ->
if String.downcase(to_string(key)) == target, do: to_string(value), else: nil
end)
end
defp synthetic_incoming(room_id, user_id, message_id, raw, event_type) do
Incoming.new(%{
external_room_id: room_id || "slack",
external_user_id: user_id,
external_message_id: message_id,
text: nil,
raw: normalize_struct(raw),
metadata: %{event_type: event_type}
})
end
defp next_cursor(result) when is_map(result) do
result
|> map_get([:response_metadata, "response_metadata"])
|> maybe_get([:next_cursor, "next_cursor"])
|> case do
"" -> nil
value -> value
end
end
defp maybe_put_thread_ts(opts) when is_list(opts) do
case Keyword.get(opts, :external_thread_id) do
nil -> opts
thread_ts -> Keyword.put_new(opts, :thread_ts, thread_ts)
end
end
defp normalize_event_type("message"), do: :message
defp normalize_event_type("app_mention"), do: :app_mention
defp normalize_event_type("reaction_added"), do: :reaction_added
defp normalize_event_type("reaction_removed"), do: :reaction_removed
defp normalize_event_type(other) when is_atom(other), do: other
defp normalize_event_type(_), do: :unknown
defp thread_id(channel_id, nil), do: "slack:#{channel_id}"
defp thread_id(channel_id, thread_ts), do: "slack:#{channel_id}:#{thread_ts}"
defp transport(%{transport: transport}) when not is_nil(transport), do: transport
defp transport(opts) when is_list(opts), do: Keyword.get(opts, :transport, ReqClient)
defp transport(_opts), do: ReqClient
defp normalize_ingress_opts(opts) do
ingress = Keyword.get(opts, :ingress, %{}) |> ensure_map()
settings_ingress = settings_ingress(opts)
Map.merge(settings_ingress, ingress)
end
defp settings_ingress(opts) do
opts
|> Keyword.get(:settings, %{})
|> ensure_map()
|> map_get([:ingress, "ingress"])
|> ensure_map()
end
defp ensure_map(%{} = map), do: map
defp ensure_map(_), do: %{}
defp ingress_mode(ingress) do
case map_get(ingress, [:mode, "mode"]) do
nil -> :webhook
:webhook -> :webhook
"webhook" -> :webhook
:socket_mode -> :socket_mode
"socket_mode" -> :socket_mode
_ -> :invalid
end
end
defp validate_sink_mfa({module, function, args})
when is_atom(module) and is_atom(function) and is_list(args),
do: {:ok, {module, function, args}}
defp validate_sink_mfa(_), do: {:error, :invalid_sink_mfa}
defp socket_mode_worker_opts(bridge_id, ingress, opts, sink_mfa) do
bridge_config = Keyword.get(opts, :bridge_config)
credentials = bridge_credentials(bridge_config)
[
bridge_id: bridge_id,
sink_mfa: sink_mfa,
sink_opts: [bridge_id: bridge_id],
app_token:
map_get(ingress, [:app_token, "app_token"]) ||
map_get(ingress, [:socket_mode_app_token, "socket_mode_app_token"]) ||
map_get(credentials, [:app_token, "app_token"]) ||
map_get(credentials, [:socket_mode_app_token, "socket_mode_app_token"]),
open_client: map_get(ingress, [:open_client, "open_client"]) || Jido.Chat.Slack.SocketMode.ReqClient,
open_client_opts:
normalize_keyword_opts(
map_get(ingress, [:open_client_opts, "open_client_opts"]) ||
map_get(ingress, [:transport_opts, "transport_opts"])
),
socket_client:
map_get(ingress, [:socket_client, "socket_client"]) ||
Jido.Chat.Slack.SocketMode.WebSockexClient,
socket_client_opts: normalize_keyword_opts(map_get(ingress, [:socket_client_opts, "socket_client_opts"])),
response_builder:
map_get(ingress, [:response_builder, "response_builder"]) ||
map_get(ingress, [:slack_response_builder, "slack_response_builder"]),
reconnect_interval_ms: map_get(ingress, [:reconnect_interval_ms, "reconnect_interval_ms"]) || 250,
max_backoff_ms: map_get(ingress, [:max_backoff_ms, "max_backoff_ms"]) || 5_000,
path_prefix: map_get(ingress, [:path_prefix, "path_prefix"]) || "/socket_mode"
]
end
defp normalize_keyword_opts(value) when is_list(value), do: value
defp normalize_keyword_opts(value) when is_map(value), do: Enum.into(value, [])
defp normalize_keyword_opts(_value), do: []
defp bridge_credentials(%{credentials: credentials}) when is_map(credentials), do: credentials
defp bridge_credentials(_), do: %{}
defp normalize_struct(%_{} = struct), do: struct |> Map.from_struct() |> normalize_struct()
defp normalize_struct(map) when is_map(map),
do: Enum.into(map, %{}, fn {k, v} -> {k, normalize_value(v)} end)
defp normalize_struct(other), do: other
defp normalize_value(%_{} = struct), do: normalize_struct(struct)
defp normalize_value(list) when is_list(list), do: Enum.map(list, &normalize_value/1)
defp normalize_value(other), do: other
defp pick_opts(opts, allowed_keys) when is_list(opts), do: Keyword.take(opts, allowed_keys)
defp map_get(nil, _keys), do: nil
defp map_get(map, keys) when is_map(map) and is_list(keys) do
Enum.find_value(keys, fn key ->
Map.get(map, key) ||
case key do
key when is_binary(key) ->
try do
Map.get(map, String.to_existing_atom(key))
rescue
ArgumentError -> nil
end
key when is_atom(key) ->
Map.get(map, Atom.to_string(key))
_ ->
nil
end
end)
end
defp maybe_get(nil, _keys), do: nil
defp maybe_get(map, keys) when is_map(map), do: map_get(map, keys)
defp maybe_get(_other, _keys), do: nil
defp stringify(nil), do: nil
defp stringify(value) when is_binary(value), do: value
defp stringify(value), do: to_string(value)
end