defmodule Jido.Chat.Discord.Adapter do
@moduledoc """
Discord `Jido.Chat.Adapter` implementation using Nostrum.
"""
use Jido.Chat.Adapter
alias Jido.Chat.{
ChannelInfo,
EphemeralMessage,
EventEnvelope,
FileUpload,
Incoming,
Message,
MessagePage,
ModalResult,
Response,
ThreadPage,
WebhookRequest,
WebhookResponse
}
alias Jido.Chat.Discord.{
DeleteOptions,
EditOptions,
FetchOptions,
GatewayWorker,
NostrumGatewayBuffer,
NostrumGatewayListener,
MetadataOptions,
ReactionOptions,
SendOptions,
TypingOptions
}
alias Jido.Chat.Discord.Transport.NostrumClient
@impl true
def channel_type, do: :discord
@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: :native,
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, []}
:gateway ->
with {:ok, sink_mfa} <- validate_sink_mfa(Keyword.get(opts, :sink_mfa)) do
gateway_listener_specs(bridge_id, ingress, sink_mfa)
end
:invalid ->
{:error, :invalid_ingress_mode}
end
end
@impl true
def transform_incoming(%Nostrum.Struct.Message{} = msg) do
do_transform_incoming(Map.from_struct(msg))
end
def transform_incoming(%{channel_id: channel_id} = msg) when is_map(msg) do
_ = channel_id
do_transform_incoming(msg)
end
def transform_incoming(%{"channel_id" => channel_id} = msg) when is_map(msg) do
_ = channel_id
do_transform_incoming(msg)
end
def transform_incoming(_), do: {:error, :unsupported_message_type}
@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)) do
{:ok,
Response.new(%{
message_id: map_get(result, [:message_id, "message_id"]),
channel_id: map_get(result, [:channel_id, "channel_id"]),
timestamp: map_get(result, [:timestamp, "timestamp"]),
channel_type: :discord,
status: :sent,
raw: result
})}
end
end
@impl true
def send_file(channel_id, file, opts \\ []) do
upload = FileUpload.normalize(file)
with {:ok, file_opt} <- upload_input(upload),
{:ok, result} <-
transport(opts).send_message(
channel_id,
upload_caption(upload),
upload_transport_opts(opts, file_opt)
) do
{:ok, upload_response(upload, result, channel_id)}
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)
) do
{:ok,
Response.new(%{
message_id: map_get(result, [:message_id, "message_id"]) || message_id,
channel_id: map_get(result, [:channel_id, "channel_id"]) || channel_id,
timestamp: map_get(result, [:timestamp, "timestamp"]),
channel_type: :discord,
status: :edited,
raw: result
})}
end
end
@impl true
def delete_message(channel_id, message_id, opts \\ []) do
opts = opts |> pick_opts([:transport, :nostrum_message_api]) |> 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
opts = opts |> pick_opts([:transport, :status, :nostrum_channel_api]) |> TypingOptions.new()
with {:ok, _result} <-
transport(opts).start_typing(channel_id, TypingOptions.transport_opts(opts)) do
:ok
end
end
@impl true
def fetch_metadata(channel_id, opts \\ []) do
opts = opts |> pick_opts([:transport, :nostrum_channel_api]) |> MetadataOptions.new()
with {:ok, result} <-
transport(opts).fetch_metadata(channel_id, MetadataOptions.transport_opts(opts)) do
{:ok, normalize_channel_info(result, channel_id)}
end
end
@impl true
def fetch_thread(channel_id, opts \\ []) do
with {:ok, info} <- fetch_metadata(channel_id, opts) do
{:ok,
%{
id: "discord:#{channel_id}",
adapter_name: :discord,
external_room_id: channel_id,
metadata: info.metadata,
channel_id: "discord:#{channel_id}"
}}
end
end
@impl true
def fetch_message(channel_id, message_id, opts \\ []) do
opts = pick_opts(opts, [:transport, :nostrum_message_api])
with {:ok, raw_message} <- transport(opts).fetch_message(channel_id, message_id, opts),
{:ok, incoming} <- transform_incoming(raw_message) do
{:ok,
Message.from_incoming(incoming,
adapter_name: :discord,
thread_id: "discord:#{incoming.external_room_id}"
)}
end
end
@impl true
def open_dm(user_id, opts \\ []) do
opts = pick_opts(opts, [:transport, :nostrum_user_api])
transport(opts).open_dm(user_id, opts)
end
@impl true
def open_thread(channel_id, message_id, opts \\ []) do
with {:ok, result} <- transport(opts).open_thread(channel_id, message_id, opts) do
{:ok,
%{
external_thread_id: stringify(result[:external_thread_id] || result["external_thread_id"]),
delivery_external_room_id:
stringify(result[:delivery_external_room_id] || result["delivery_external_room_id"]),
parent_id: stringify(result[:parent_id] || result["parent_id"])
}}
end
end
@impl true
def post_ephemeral(_channel_id, user_id, text, opts \\ []) do
interaction_id = opts[:interaction_id]
interaction_token = opts[:interaction_token]
cond do
interaction_id && is_binary(interaction_token) ->
interaction_opts = pick_opts(opts, [:transport, :nostrum_interaction_api])
payload = %{
type: 4,
data: %{content: text, flags: 64}
}
with {:ok, _response} <-
transport(interaction_opts).create_interaction_response(
interaction_id,
interaction_token,
payload,
interaction_opts
) do
{:ok,
EphemeralMessage.new(%{
id: "discord:interaction:#{interaction_id}",
thread_id: "discord:interaction:#{interaction_id}",
used_fallback: false,
raw: payload,
metadata: %{interaction_id: to_string(interaction_id)}
})}
end
Keyword.get(opts, :fallback_to_dm, false) ->
send_opts = Keyword.drop(opts, [:fallback_to_dm])
with {:ok, dm_room_id} <- open_dm(user_id, send_opts),
{:ok, %Response{} = response} <- send_message(dm_room_id, text, send_opts) do
{:ok,
EphemeralMessage.new(%{
id: response.external_message_id || Jido.Chat.ID.generate!(),
thread_id: "discord:#{dm_room_id}",
used_fallback: true,
raw: response.raw,
metadata: %{channel_id: dm_room_id}
})}
end
true ->
{:error, :unsupported}
end
end
@impl true
def open_modal(channel_id, payload, opts \\ []) when is_map(payload) do
interaction_id = opts[:interaction_id]
interaction_token = opts[:interaction_token]
cond do
is_nil(interaction_id) or not is_binary(interaction_token) ->
{:error, :missing_interaction_context}
true ->
interaction_opts = pick_opts(opts, [:transport, :nostrum_interaction_api])
request_payload = %{
type: 9,
data: normalize_modal_payload(payload)
}
with {:ok, _response} <-
transport(interaction_opts).create_interaction_response(
interaction_id,
interaction_token,
request_payload,
interaction_opts
) do
{:ok,
ModalResult.new(%{
id: "discord:modal:#{interaction_id}",
status: :opened,
external_room_id: channel_id,
raw: request_payload,
metadata: %{interaction_id: to_string(interaction_id)}
})}
end
end
end
@impl true
def add_reaction(channel_id, message_id, emoji, opts \\ []) do
opts = opts |> pick_opts([:transport, :nostrum_message_api]) |> 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([:transport, :nostrum_message_api]) |> 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([:transport, :cursor, :limit, :direction, :nostrum_channel_api])
|> FetchOptions.new()
with {:ok, result} <-
transport(opts).fetch_messages(channel_id, FetchOptions.transport_opts(opts)) do
{:ok,
MessagePage.new(%{
messages: normalize_messages(map_get(result, [:messages, "messages"]) || []),
next_cursor: map_get(result, [:next_cursor, "next_cursor"]),
direction: opts.direction,
metadata: %{raw: result}
})}
end
end
@impl true
def fetch_channel_messages(channel_id, opts \\ []) do
fetch_messages(channel_id, opts)
end
@impl true
def list_threads(channel_id, opts \\ []) do
opts = pick_opts(opts, [:transport, :nostrum_channel_api])
with {:ok, result} <- transport(opts).list_threads(channel_id, opts) do
threads = map_get(result, [:threads, "threads"]) || []
next_cursor = map_get(result, [:next_cursor, "next_cursor"])
{:ok,
ThreadPage.new(%{
threads: Enum.map(threads, &normalize_thread_summary(channel_id, &1)),
next_cursor: stringify(next_cursor),
metadata: %{raw: result}
})}
end
end
@impl true
def verify_webhook(%WebhookRequest{} = request, opts \\ []) do
public_key =
opts[:discord_public_key] || opts[:public_key] ||
Application.get_env(:jido_chat_discord, :discord_public_key)
if is_nil(public_key) do
:ok
else
verify_discord_signature(request, public_key, opts)
end
end
@impl true
def parse_event(%WebhookRequest{} = request, _opts \\ []) do
parse_payload_event(request.payload, request.path)
end
@impl true
def format_webhook_response(result, opts \\ [])
def format_webhook_response({:ok, _chat, :noop}, opts) do
if ping_request?(opts) do
WebhookResponse.new(%{status: 200, body: %{type: 1}})
else
WebhookResponse.accepted(%{ok: true})
end
end
def format_webhook_response({:ok, _chat, _event}, _opts) do
WebhookResponse.accepted(%{ok: true})
end
def format_webhook_response({:error, :invalid_signature}, _opts) do
WebhookResponse.error(401, %{error: "invalid_signature"})
end
def format_webhook_response({:error, reason}, _opts) do
WebhookResponse.error(400, %{error: to_string(reason)})
end
@impl true
def handle_webhook(%Jido.Chat{} = chat, payload, opts \\ []) when is_map(payload) do
request =
WebhookRequest.new(%{
adapter_name: :discord,
headers: opts[:headers] || %{},
payload: payload,
raw: opts[:raw_body] || payload,
metadata: %{raw_body: opts[:raw_body]}
})
with :ok <- verify_webhook(request, opts) do
with {: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
end
@doc "Gateway helper for non-webhook event families routed into `Jido.Chat.process_*`."
@spec handle_gateway_event(Jido.Chat.t(), map() | {atom() | String.t(), map()}, keyword()) ::
{:ok, Jido.Chat.t(), term()} | {:error, term()}
def handle_gateway_event(chat, event, opts \\ [])
def handle_gateway_event(%Jido.Chat{} = chat, {event_name, payload}, opts)
when is_map(payload) do
handle_gateway_event(chat, %{event: event_name, payload: payload}, opts)
end
def handle_gateway_event(%Jido.Chat{} = chat, %{} = event, opts) do
event_name =
event[:event] || event["event"] || event[:type] || event["type"] ||
event[:t] || event["t"]
payload = event[:payload] || event["payload"] || event
case normalize_event_name(event_name) do
"MESSAGE_REACTION_ADD" -> process_gateway_reaction(chat, payload, true, opts)
"MESSAGE_REACTION_REMOVE" -> process_gateway_reaction(chat, payload, false, opts)
"MESSAGE_CREATE" -> handle_webhook(chat, payload, opts)
"INTERACTION_CREATE" -> handle_webhook(chat, payload, opts)
"MODAL_CLOSE" -> process_gateway_modal_close(chat, payload, opts)
_ -> {:error, :unsupported_gateway_event}
end
end
defp route_parsed_event(chat, :noop, _opts, %WebhookRequest{} = request) do
payload = request.payload
if interaction_ping_payload?(payload) do
{:ok, chat, synthetic_incoming("interaction", "discord", "ping", payload, :ping)}
else
{:error, :unsupported_message_type}
end
end
defp route_parsed_event(chat, %EventEnvelope{} = envelope, opts, _request) do
with {:ok, updated_chat, routed_envelope} <-
Jido.Chat.process_event(chat, :discord, 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: event_type, raw: raw})
when event_type in [:slash_command, :action, :modal_submit] do
channel_id = map_get(raw, [:channel_id, "channel_id"])
user = interaction_user(raw)
message_id = map_get(raw, [:id, "id"])
{:ok,
synthetic_incoming(
channel_id,
user.user_id,
message_id,
raw,
event_type
)}
end
defp incoming_from_event(_), do: {:error, :unsupported_message_type}
defp parse_payload_event(payload, path) when is_map(payload) do
cond do
interaction_ping_payload?(payload) ->
{:ok, :noop}
interaction_payload?(payload) ->
interaction_event_envelope(payload)
true ->
with {:ok, incoming} <- transform_incoming(payload) do
{:ok,
EventEnvelope.new(%{
adapter_name: :discord,
event_type: :message,
thread_id: thread_id(incoming),
channel_id: stringify(incoming.external_room_id),
message_id: stringify(incoming.external_message_id),
payload: incoming,
raw: payload,
metadata: %{path: path}
})}
end
end
end
defp parse_payload_event(_payload, _path), do: {:error, :unsupported_message_type}
defp upload_transport_opts(opts, file_opt) do
opts
|> pick_opts([
:transport,
:embeds,
:components,
:tts,
:allowed_mentions,
:message_reference,
:reply_to_id,
:nostrum_message_api
])
|> Keyword.put(:file, file_opt)
end
defp upload_response(%FileUpload{} = upload, result, channel_id) do
attachment =
(result[:attachments] || result["attachments"] || [])
|> List.first()
|> normalize_map()
delivered_kind =
case attachment[:content_type] || attachment["content_type"] do
<<"image/", _::binary>> -> :image
_ -> upload.kind
end
Response.new(%{
message_id: stringify(result[:message_id] || result["message_id"]),
channel_id: stringify(result[:channel_id] || result["channel_id"] || channel_id),
timestamp: result[:timestamp] || result["timestamp"],
channel_type: :discord,
status: :sent,
raw: result[:raw] || result["raw"] || result,
metadata:
%{
attachment_id: attachment[:id] || attachment["id"],
filename: attachment[:filename] || attachment["filename"],
size: attachment[:size] || attachment["size"],
url: attachment[:url] || attachment["url"],
content_type: attachment[:content_type] || attachment["content_type"],
upload_kind: upload.kind,
delivered_kind: delivered_kind
}
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
|> Map.new()
})
end
defp upload_input(%FileUpload{path: path}) when is_binary(path) and path != "", do: {:ok, path}
defp upload_input(%FileUpload{data: data, filename: filename})
when is_binary(data) and data != "" and is_binary(filename) and filename != "" do
{:ok, %{body: data, name: filename}}
end
defp upload_input(%FileUpload{data: data}) when is_binary(data) and data != "" do
{:error, :missing_filename}
end
defp upload_input(%FileUpload{url: url}) when is_binary(url) and url != "" do
{:error, :unsupported_remote_url}
end
defp upload_input(_upload), do: {:error, :missing_file_source}
defp upload_caption(%FileUpload{} = upload) do
metadata = upload.metadata
metadata[:caption] || metadata["caption"] || metadata[:alt_text] || metadata["alt_text"] ||
metadata[:transcript] || metadata["transcript"]
end
defp normalize_map(%_{} = struct), do: Map.from_struct(struct)
defp normalize_map(map) when is_map(map), do: map
defp normalize_map(_), do: %{}
defp interaction_event_envelope(payload) do
type = map_get(payload, [:type, "type"])
channel_id = map_get(payload, [:channel_id, "channel_id"])
user = interaction_user(payload)
case type do
2 ->
slash_payload = %{
adapter_name: :discord,
channel_id: stringify(channel_id),
command: "/" <> to_string(map_get(payload, [:data, "data"]) |> map_get([:name, "name"])),
text: slash_arguments(payload),
user: user,
raw: payload,
metadata: %{interaction_type: type}
}
{:ok,
EventEnvelope.new(%{
adapter_name: :discord,
event_type: :slash_command,
thread_id: "discord:#{channel_id}",
channel_id: stringify(channel_id),
message_id: stringify(map_get(payload, [:id, "id"])),
payload: slash_payload,
raw: payload,
metadata: %{}
})}
3 ->
data = map_get(payload, [:data, "data"]) || %{}
message = map_get(payload, [:message, "message"]) || %{}
action_payload = %{
adapter_name: :discord,
thread_id: "discord:#{channel_id}",
message_id: stringify(map_get(data, [:message_id, "message_id"]) || map_get(message, [:id, "id"])),
action_id: map_get(data, [:custom_id, "custom_id"]),
value: inspect(map_get(data, [:values, "values"]) || %{}),
user: user,
raw: payload,
metadata: %{interaction_type: type}
}
{:ok,
EventEnvelope.new(%{
adapter_name: :discord,
event_type: :action,
thread_id: "discord:#{channel_id}",
channel_id: stringify(channel_id),
message_id: stringify(map_get(payload, [:id, "id"])),
payload: action_payload,
raw: payload,
metadata: %{}
})}
5 ->
data = map_get(payload, [:data, "data"]) || %{}
modal_payload = %{
adapter_name: :discord,
callback_id: map_get(data, [:custom_id, "custom_id"]),
view_id: map_get(payload, [:id, "id"]) |> stringify(),
values: normalize_modal_values(data),
user: user,
raw: payload,
metadata: %{interaction_type: type}
}
{:ok,
EventEnvelope.new(%{
adapter_name: :discord,
event_type: :modal_submit,
thread_id: "discord:#{channel_id}",
channel_id: stringify(channel_id),
message_id: stringify(map_get(payload, [:id, "id"])),
payload: modal_payload,
raw: payload,
metadata: %{}
})}
_ ->
{:error, :unsupported_interaction_type}
end
end
defp process_gateway_reaction(chat, payload, added, opts) do
reaction_payload = %{
adapter_name: :discord,
thread_id: "discord:#{map_get(payload, [:channel_id, "channel_id"])}",
message_id: stringify(map_get(payload, [:message_id, "message_id"])),
emoji: reaction_emoji(payload),
added: added,
user: %{
user_id: stringify(map_get(payload, [:user_id, "user_id"]) || "unknown"),
user_name: stringify(map_get(payload, [:user_id, "user_id"]) || "unknown")
},
raw: payload,
metadata: %{}
}
with {:ok, updated_chat, event} <-
Jido.Chat.process_reaction(chat, :discord, reaction_payload, opts) do
{:ok, updated_chat, event}
end
end
defp process_gateway_modal_close(chat, payload, opts) do
modal_close_payload = %{
adapter_name: :discord,
callback_id: stringify(map_get(payload, [:custom_id, "custom_id"])),
view_id: stringify(map_get(payload, [:id, "id"])),
raw: payload,
metadata: %{}
}
with {:ok, updated_chat, event} <-
Jido.Chat.process_modal_close(chat, :discord, modal_close_payload, opts) do
{:ok, updated_chat, event}
end
end
defp interaction_payload?(payload) when is_map(payload) do
type = map_get(payload, [:type, "type"])
type in [1, 2, 3, 5]
end
defp interaction_ping_payload?(payload) when is_map(payload),
do: map_get(payload, [:type, "type"]) == 1
defp interaction_user(payload) do
member_user = map_get(payload, [:member, "member"]) |> map_get([:user, "user"])
user = member_user || map_get(payload, [:user, "user"]) || %{}
%{
user_id: stringify(map_get(user, [:id, "id"]) || "unknown"),
user_name:
map_get(user, [:username, "username"]) ||
stringify(map_get(user, [:id, "id"]) || "unknown"),
full_name:
map_get(user, [:global_name, "global_name"]) ||
map_get(user, [:username, "username"])
}
end
defp slash_arguments(payload) do
payload
|> map_get([:data, "data"])
|> map_get([:options, "options"])
|> case do
list when is_list(list) ->
list
|> Enum.map(fn entry -> map_get(entry, [:value, "value"]) end)
|> Enum.reject(&is_nil/1)
|> Enum.map_join(" ", &to_string/1)
_ ->
""
end
end
defp normalize_modal_values(data) do
data
|> map_get([:components, "components"])
|> case do
components when is_list(components) ->
Enum.reduce(components, %{}, fn component, acc ->
inner = map_get(component, [:components, "components"]) || []
Enum.reduce(inner, acc, fn field, inner_acc ->
custom_id = map_get(field, [:custom_id, "custom_id"])
value = map_get(field, [:value, "value"])
if is_nil(custom_id), do: inner_acc, else: Map.put(inner_acc, custom_id, value)
end)
end)
_ ->
%{}
end
end
defp synthetic_incoming(channel_id, user_id, message_id, raw, event_type) do
Incoming.new(%{
external_room_id: channel_id || "unknown",
external_user_id: user_id,
external_message_id: stringify(message_id),
text: nil,
raw: raw,
metadata: %{event_type: event_type}
})
end
defp reaction_emoji(payload) do
emoji = map_get(payload, [:emoji, "emoji"]) || %{}
map_get(emoji, [:name, "name"]) || ""
end
defp normalize_event_name(name) when is_atom(name),
do: name |> Atom.to_string() |> String.upcase()
defp normalize_event_name(name) when is_binary(name), do: String.upcase(name)
defp normalize_event_name(_), do: ""
defp verify_discord_signature(%WebhookRequest{} = request, public_key, opts) do
signature =
request.headers["x-signature-ed25519"] ||
request.headers["X-Signature-Ed25519"]
timestamp =
request.headers["x-signature-timestamp"] ||
request.headers["X-Signature-Timestamp"]
raw_body =
opts[:raw_body] ||
request.metadata[:raw_body] ||
request.metadata["raw_body"] ||
request.raw
with true <- is_binary(signature),
true <- is_binary(timestamp),
true <- is_binary(raw_body),
{:ok, signature_bytes} <- decode_hex(signature),
{:ok, public_key_bytes} <- decode_hex(public_key),
true <-
:crypto.verify(
:eddsa,
:none,
timestamp <> raw_body,
signature_bytes,
[public_key_bytes, :ed25519]
) do
:ok
else
_ -> {:error, :invalid_signature}
end
end
defp decode_hex(hex) when is_binary(hex) do
case Base.decode16(hex, case: :mixed) do
{:ok, value} -> {:ok, value}
:error -> {:error, :invalid_hex}
end
end
defp transport(%{transport: transport}) when not is_nil(transport), do: transport
defp transport(opts) when is_list(opts), do: Keyword.get(opts, :transport, NostrumClient)
defp transport(_opts), do: NostrumClient
defp thread_id(%Incoming{external_room_id: room_id, external_thread_id: nil}),
do: "discord:#{room_id}"
defp thread_id(%Incoming{external_room_id: room_id, external_thread_id: thread_id}),
do: "discord:#{room_id}:#{thread_id}"
defp get_map_value(map, keys) when is_map(map),
do: Enum.find_value(keys, fn key -> Map.get(map, key) end)
defp get_nested_id(nil), do: nil
defp get_nested_id(author) when is_map(author), do: get_map_value(author, [:id, "id"])
defp get_nested_username(nil), do: nil
defp get_nested_username(author) when is_map(author),
do: get_map_value(author, [:username, "username"])
defp get_nested_display_name(nil), do: nil
defp get_nested_display_name(author) when is_map(author) do
get_map_value(author, [:global_name, "global_name"]) ||
get_map_value(author, [:username, "username"])
end
defp parse_chat_type(msg) when is_map(msg) do
channel_type = get_map_value(msg, [:type, "type"])
guild_id = get_map_value(msg, [:guild_id, "guild_id"])
cond do
thread_channel_type?(channel_type) -> :thread
is_nil(guild_id) -> :dm
true -> :guild
end
end
defp do_transform_incoming(msg) when is_map(msg) do
route = thread_route(msg)
chat_type = parse_chat_type(msg)
{:ok,
Incoming.new(%{
external_room_id: route.external_room_id,
external_user_id: get_map_value(msg, [:author, "author"]) |> get_nested_id(),
text: get_map_value(msg, [:content, "content"]),
media: extract_media(msg),
username: get_map_value(msg, [:author, "author"]) |> get_nested_username(),
display_name: get_map_value(msg, [:author, "author"]) |> get_nested_display_name(),
external_message_id: get_map_value(msg, [:id, "id"]),
timestamp: get_map_value(msg, [:timestamp, "timestamp"]),
chat_type: chat_type,
chat_title: nil,
external_thread_id: route.external_thread_id,
delivery_external_room_id: route.delivery_external_room_id,
channel_meta: %{
adapter_name: :discord,
external_room_id: route.external_room_id,
external_thread_id: route.external_thread_id,
delivery_external_room_id: route.delivery_external_room_id,
chat_type: chat_type,
chat_title: nil,
is_dm: chat_type == :dm,
metadata: %{parent_id: route.parent_id, channel_id: route.channel_id}
},
raw: msg
})}
end
defp thread_route(msg) when is_map(msg) do
channel_id = stringify(get_map_value(msg, [:channel_id, "channel_id"]))
nested_parent_id =
case get_map_value(msg, [:thread, "thread"]) do
thread when is_map(thread) -> get_map_value(thread, [:parent_id, "parent_id"])
_ -> nil
end
parent_id =
stringify(get_map_value(msg, [:parent_id, "parent_id"]) || nested_parent_id)
channel_type = get_map_value(msg, [:type, "type"])
if thread_channel_type?(channel_type) or not is_nil(parent_id) do
%{
channel_id: channel_id,
parent_id: parent_id || channel_id,
external_room_id: parent_id || channel_id,
external_thread_id: channel_id,
delivery_external_room_id: channel_id
}
else
%{
channel_id: channel_id,
parent_id: nil,
external_room_id: channel_id,
external_thread_id: nil,
delivery_external_room_id: channel_id
}
end
end
defp thread_channel_type?(type), do: type in [10, 11, 12, :thread, "thread"]
defp extract_media(%Nostrum.Struct.Message{attachments: attachments})
when is_list(attachments) do
attachments
|> Enum.map(&normalize_attachment/1)
|> Enum.reject(&is_nil/1)
end
defp extract_media(msg) when is_map(msg) do
msg
|> get_map_value([:attachments, "attachments"])
|> normalize_attachments()
end
defp normalize_attachments(attachments) when is_list(attachments) do
attachments
|> Enum.map(&normalize_attachment/1)
|> Enum.reject(&is_nil/1)
end
defp normalize_attachments(_), do: []
defp normalize_attachment(%_{} = attachment),
do: attachment |> Map.from_struct() |> normalize_attachment()
defp normalize_attachment(attachment) when is_map(attachment) do
media_type = get_map_value(attachment, [:content_type, "content_type"])
filename = get_map_value(attachment, [:filename, "filename", :name, "name"])
url = get_map_value(attachment, [:url, "url", :proxy_url, "proxy_url"])
kind = attachment_kind(media_type)
%{
kind: kind,
url: url,
media_type: media_type,
filename: filename,
size_bytes: get_map_value(attachment, [:size, "size"]),
width: get_map_value(attachment, [:width, "width"]),
height: get_map_value(attachment, [:height, "height"])
}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
end
defp normalize_attachment(_), do: nil
defp attachment_kind(media_type) when is_binary(media_type) do
cond do
String.starts_with?(media_type, "image/") -> :image
String.starts_with?(media_type, "audio/") -> :audio
String.starts_with?(media_type, "video/") -> :video
true -> :file
end
end
defp attachment_kind(_), do: :file
defp normalize_channel_info(%ChannelInfo{} = info, _channel_id), do: info
defp normalize_channel_info(%{} = info, channel_id) do
type = map_get(info, [:type, "type"])
ChannelInfo.new(%{
id: to_string(map_get(info, [:id, "id"]) || channel_id),
name: map_get(info, [:name, "name"]),
is_dm: type in [1, :dm, "dm"],
member_count: map_get(info, [:member_count, "member_count"]),
metadata: info
})
end
defp normalize_channel_info(_other, channel_id),
do: ChannelInfo.new(%{id: to_string(channel_id)})
defp normalize_messages(messages) when is_list(messages) do
Enum.flat_map(messages, fn raw_message ->
case transform_incoming(raw_message) do
{:ok, incoming} -> [Message.from_incoming(incoming, adapter_name: :discord)]
_ -> []
end
end)
end
defp normalize_thread_summary(channel_id, raw_thread) when is_map(raw_thread) do
thread_id = map_get(raw_thread, [:id, "id"])
%{
id: "discord:#{channel_id}:#{thread_id}",
reply_count:
map_get(raw_thread, [:message_count, "message_count"]) ||
map_get(raw_thread, [:member_count, "member_count"]),
metadata: raw_thread
}
end
defp normalize_modal_payload(payload) when is_map(payload) do
%{
custom_id: stringify(map_get(payload, [:custom_id, "custom_id"]) || "modal"),
title: to_string(map_get(payload, [:title, "title"]) || "Modal"),
components: map_get(payload, [:components, "components"]) || []
}
end
defp ping_request?(opts) when is_list(opts) do
request = Keyword.get(opts, :request)
case request do
%WebhookRequest{} -> interaction_ping_payload?(request.payload)
map when is_map(map) -> interaction_ping_payload?(map[:payload] || map["payload"] || map)
_ -> false
end
end
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) end)
end
defp map_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)
defp pick_opts(opts, allowed_keys) when is_list(opts), do: Keyword.take(opts, allowed_keys)
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()
|> get_map_value([:ingress, "ingress"])
|> ensure_map()
end
defp ensure_map(%{} = map), do: map
defp ensure_map(_), do: %{}
defp ingress_mode(ingress) do
case get_map_value(ingress, [:mode, "mode"]) do
nil -> :webhook
:webhook -> :webhook
:gateway -> :gateway
"webhook" -> :webhook
"gateway" -> :gateway
_ -> :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 gateway_worker_opts(bridge_id, ingress, sink_mfa) do
[
bridge_id: bridge_id,
sink_mfa: sink_mfa,
sink_opts: [bridge_id: bridge_id],
event_source_mfa: get_map_value(ingress, [:event_source_mfa, "event_source_mfa"]),
poll_interval_ms: get_map_value(ingress, [:poll_interval_ms, "poll_interval_ms"]) || 250,
max_backoff_ms: get_map_value(ingress, [:max_backoff_ms, "max_backoff_ms"]) || 5_000
]
end
defp gateway_listener_specs(bridge_id, ingress, sink_mfa) do
case ingress_source(ingress) do
:nostrum ->
{:ok,
[
Supervisor.child_spec(
{NostrumGatewayBuffer, nostrum_gateway_buffer_opts(bridge_id, ingress)},
id: {:discord_gateway_buffer, bridge_id}
),
Supervisor.child_spec(
{NostrumGatewayListener, nostrum_gateway_listener_opts(bridge_id, ingress)},
id: {:discord_gateway_listener, bridge_id}
),
Supervisor.child_spec(
{GatewayWorker,
gateway_worker_opts(
bridge_id,
Map.put(
ingress,
:event_source_mfa,
{NostrumGatewayBuffer, :pop_events, [bridge_id]}
),
sink_mfa
)},
id: {:discord_gateway_worker, bridge_id}
)
]}
:mfa ->
case get_map_value(ingress, [:event_source_mfa, "event_source_mfa"]) do
{module, function, args} when is_atom(module) and is_atom(function) and is_list(args) ->
{:ok,
[
Supervisor.child_spec(
{GatewayWorker, gateway_worker_opts(bridge_id, ingress, sink_mfa)},
id: {:discord_gateway_worker, bridge_id}
)
]}
_other ->
{:error, :invalid_event_source_mfa}
end
:invalid ->
{:error, :invalid_ingress_source}
end
end
defp ingress_source(ingress) do
source = get_map_value(ingress, [:source, "source"])
event_source_mfa = get_map_value(ingress, [:event_source_mfa, "event_source_mfa"])
cond do
source in [:nostrum, "nostrum"] ->
:nostrum
source in [:mfa, "mfa"] ->
:mfa
source in [nil, ""] and is_tuple(event_source_mfa) ->
:mfa
source in [nil, ""] ->
:nostrum
true ->
:invalid
end
end
defp nostrum_gateway_buffer_opts(bridge_id, ingress) do
[
bridge_id: bridge_id,
max_events: get_map_value(ingress, [:max_events, "max_events"]) || 1_000
]
end
defp nostrum_gateway_listener_opts(bridge_id, ingress) do
[
bridge_id: bridge_id,
event_names: get_map_value(ingress, [:event_names, "event_names"])
]
end
end