diff --git a/config/config.exs b/config/config.exs index 69d5db09..18a3fd9a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -30,7 +30,8 @@ config :ash_hq, AshHq.Blog, AshHq.Docs, AshHq.Github, - AshHq.MailingList + AshHq.MailingList, + AshHq.Discord ] config :ash_hq, AshHq.Repo, diff --git a/lib/ash_hq/discord/discord.ex b/lib/ash_hq/discord/discord.ex new file mode 100644 index 00000000..4bc59614 --- /dev/null +++ b/lib/ash_hq/discord/discord.ex @@ -0,0 +1,14 @@ +defmodule AshHq.Discord do + @moduledoc "Discord api import & interactions" + use Ash.Api + + resources do + resource AshHq.Discord.Attachment + resource AshHq.Discord.Channel + resource AshHq.Discord.Message + resource AshHq.Discord.Reaction + resource AshHq.Discord.Tag + resource AshHq.Discord.Thread + resource AshHq.Discord.ThreadTag + end +end diff --git a/lib/ash_hq/discord/listener.ex b/lib/ash_hq/discord/listener.ex deleted file mode 100644 index f661e256..00000000 --- a/lib/ash_hq/discord/listener.ex +++ /dev/null @@ -1,138 +0,0 @@ -defmodule AshHq.Discord.Listener do - @moduledoc """ - Does nothing for now. Eventually will support slash commands to search AshHQ from discord. - """ - use Nostrum.Consumer - - import Bitwise - - @user_id 1_066_406_803_769_933_834 - @server_id 711_271_361_523_351_632 - - def start_link do - Consumer.start_link(__MODULE__) - end - - def search_results!(interaction) do - search = - interaction.data.options - |> Enum.find_value(fn option -> - if option.name == "search" do - option.value - end - end) - - item_list = AshHq.Docs.Indexer.search(search) - - item_list = Enum.take(item_list, 10) - - count = - case Enum.count(item_list) do - 10 -> - "the top 10" - - other -> - "#{other}" - end - - """ - Found #{count} results for "#{search}": - - #{Enum.map_join(item_list, "\n", &render_search_result(&1))} - """ - end - - defp render_search_result(item) do - link = - case item do - %AshHq.Docs.Guide{} -> - Path.join("https://ash-hq.org", AshHqWeb.DocRoutes.doc_link(item)) - - item -> - AshHqWeb.DocRoutes.doc_link(item) - end - - case item do - %{name: name} -> - "* #{name}: #{link}" - - _ -> - "* forum message: #{link}" - end - end - - def handle_event({:INTERACTION_CREATE, %Nostrum.Struct.Interaction{} = interaction, _ws_state}) do - public = - interaction.data.options - |> Enum.find_value(fn option -> - if option.name == "public" do - option.value - end - end) - - response = %{ - # ChannelMessageWithSource - type: 4, - data: %{ - content: search_results!(interaction), - flags: - if public do - 1 <<< 2 - else - 1 <<< 6 ||| 1 <<< 2 - end - } - } - - Nostrum.Api.create_interaction_response(interaction, response) - end - - def handle_event({:READY, _msg, _ws_state}) do - # What is happening? For some reason startup is getting timeouts at the ecto pool? - Task.async(fn -> - :timer.sleep(:timer.seconds(30)) - rebuild() - end) - end - - # Default event handler, if you don't include this, your consumer WILL crash if - # you don't have a method definition for each event type. - def handle_event(_event) do - :noop - end - - def rebuild do - if Application.get_env(:ash_hq, :discord_bot) do - build_search_action() - end - end - - defp build_search_action do - command = %{ - name: "ash_hq_search", - description: "Search AshHq Documentation", - options: [ - %{ - # ApplicationCommandType::STRING - type: 3, - name: "search", - description: "what you want to search for", - required: true - }, - %{ - # ApplicationCommandType::Boolean - type: 5, - name: "public", - description: "If the results should be shown publicly in the channel", - required: false - } - ] - } - - Nostrum.Api.create_guild_application_command( - @user_id, - @server_id, - command - ) - end -end diff --git a/lib/ash_hq/discord/resources/attachment.ex b/lib/ash_hq/discord/resources/attachment.ex new file mode 100644 index 00000000..54d124fa --- /dev/null +++ b/lib/ash_hq/discord/resources/attachment.ex @@ -0,0 +1,35 @@ +defmodule AshHq.Discord.Attachment do + @moduledoc "A discord attachment on a message" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + attribute :filename, :string + attribute :size, :integer + attribute :url, :string + attribute :proxy_url, :string + attribute :height, :integer + attribute :width, :integer + end + + relationships do + belongs_to :message, AshHq.Discord.Message do + allow_nil? false + attribute_type :integer + end + end + + postgres do + table "discord_attachments" + repo AshHq.Repo + + references do + reference :message, on_delete: :delete, on_update: :update + end + end +end diff --git a/lib/ash_hq/discord/resources/channel.ex b/lib/ash_hq/discord/resources/channel.ex new file mode 100644 index 00000000..a9725bd1 --- /dev/null +++ b/lib/ash_hq/discord/resources/channel.ex @@ -0,0 +1,43 @@ +defmodule AshHq.Discord.Channel do + @moduledoc """ + The channel is the discord forum channel. We explicitly configure which ones we import. + """ + + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + + create :upsert do + upsert? true + end + end + + attributes do + integer_primary_key :id, writable?: true, generated?: false + + attribute :name, :string do + allow_nil? false + end + + attribute :order, :integer do + allow_nil? false + end + end + + relationships do + has_many :threads, AshHq.Discord.Thread + end + + postgres do + table "discord_channels" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :read + define :upsert + end +end diff --git a/lib/ash_hq/discord/resources/message.ex b/lib/ash_hq/discord/resources/message.ex new file mode 100644 index 00000000..26d6eb7c --- /dev/null +++ b/lib/ash_hq/discord/resources/message.ex @@ -0,0 +1,96 @@ +defmodule AshHq.Discord.Message do + @moduledoc """ + Discord messages synchronized by the discord bot + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer, + extensions: [ + AshHq.Docs.Extensions.RenderMarkdown, + AshHq.Docs.Extensions.Search + ] + + actions do + defaults [:read, :destroy] + + create :create do + primary? true + argument :attachments, {:array, :map} + argument :reactions, {:array, :map} + change manage_relationship(:attachments, type: :direct_control) + + change manage_relationship(:reactions, + type: :direct_control, + use_identities: [:unique_message_emoji] + ) + end + + update :update do + primary? true + argument :attachments, {:array, :map} + argument :reactions, {:array, :map} + change manage_relationship(:attachments, type: :direct_control) + + change manage_relationship(:reactions, + type: :direct_control, + use_identities: [:unique_message_emoji] + ) + end + end + + render_markdown do + render_attributes content: :content_html + end + + search do + doc_attribute :content + + type "Forum" + + load_for_search [ + :channel_name, + :thread_name + ] + + has_name_attribute? false + weight_content(-0.7) + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + + attribute :author, :string do + allow_nil? false + end + + attribute :content, :string + attribute :content_html, :string + + attribute :timestamp, :utc_datetime do + allow_nil? false + end + end + + relationships do + belongs_to :thread, AshHq.Discord.Thread do + attribute_type :integer + allow_nil? false + end + + has_many :attachments, AshHq.Discord.Attachment + has_many :reactions, AshHq.Discord.Reaction + end + + postgres do + table "discord_messages" + repo AshHq.Repo + + references do + reference :thread, on_delete: :delete, on_update: :update + end + end + + aggregates do + first :channel_name, [:thread, :channel], :name + first :thread_name, [:thread], :name + end +end diff --git a/lib/ash_hq/discord/resources/reaction.ex b/lib/ash_hq/discord/resources/reaction.ex new file mode 100644 index 00000000..6a009a71 --- /dev/null +++ b/lib/ash_hq/discord/resources/reaction.ex @@ -0,0 +1,43 @@ +defmodule AshHq.Discord.Reaction do + @moduledoc """ + Reactions store emoji reaction counts. + """ + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + end + + attributes do + uuid_primary_key :id + + attribute :count, :integer do + allow_nil? false + end + + attribute :emoji, :string do + allow_nil? false + end + end + + relationships do + belongs_to :message, AshHq.Discord.Message do + attribute_type :integer + allow_nil? false + end + end + + postgres do + table "discord_reactions" + repo AshHq.Repo + + references do + reference :message, on_delete: :delete, on_update: :update + end + end + + identities do + identity :unique_message_emoji, [:emoji, :message_id] + end +end diff --git a/lib/ash_hq/discord/resources/tag.ex b/lib/ash_hq/discord/resources/tag.ex new file mode 100644 index 00000000..73865544 --- /dev/null +++ b/lib/ash_hq/discord/resources/tag.ex @@ -0,0 +1,45 @@ +defmodule AshHq.Discord.Tag do + @moduledoc "A tag that can be applied to a post. Currently uses CSV data layer and therefore is static" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:create, :read, :update, :destroy] + + create :upsert do + upsert? true + upsert_identity :unique_name_per_channel + end + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + + attribute :name, :ci_string do + allow_nil? false + end + end + + relationships do + belongs_to :channel, AshHq.Discord.Channel do + attribute_type :integer + attribute_writable? true + end + end + + postgres do + table "discord_tags" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :upsert, args: [:channel_id, :id, :name] + define :read + define :destroy + end + + identities do + identity :unique_name_per_channel, [:name, :channel_id] + end +end diff --git a/lib/ash_hq/discord/resources/thread.ex b/lib/ash_hq/discord/resources/thread.ex new file mode 100644 index 00000000..9d277da8 --- /dev/null +++ b/lib/ash_hq/discord/resources/thread.ex @@ -0,0 +1,109 @@ +defmodule AshHq.Discord.Thread do + @moduledoc """ + A thread is an individual forum post (because they are really just fancy threads) + """ + + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + import Ecto.Query + + actions do + defaults [:create, :read, :update, :destroy] + + read :feed do + pagination do + countable true + offset? true + default_limit 25 + end + + argument :channel, :integer do + allow_nil? false + end + + argument :tag_name, :string + + prepare build(sort: [create_timestamp: :desc]) + + filter expr( + channel_id == ^arg(:channel) and + (is_nil(^arg(:tag_name)) or tags.name == ^arg(:tag_name)) + ) + end + + create :upsert do + upsert? true + argument :messages, {:array, :map} + argument :tags, {:array, :integer} + + change manage_relationship(:messages, type: :direct_control) + + change fn changeset, _ -> + Ash.Changeset.after_action(changeset, fn changeset, thread -> + tags = Ash.Changeset.get_argument(changeset, :tags) || [] + + # Not optimized in `manage_relationship` + # bulk actions should make this unnecessary + to_delete = + from thread_tag in AshHq.Discord.ThreadTag, + where: thread_tag.thread_id == ^thread.id, + where: thread_tag.tag_id not in ^tags + + AshHq.Repo.delete_all(to_delete) + + Enum.map(tags, fn tag -> + AshHq.Discord.ThreadTag.tag!(thread.id, tag) + end) + + {:ok, thread} + end) + end + end + end + + attributes do + integer_primary_key :id, generated?: false, writable?: true + attribute :type, :integer + + attribute :name, :string do + allow_nil? false + end + + attribute :author, :string do + allow_nil? false + end + + attribute :create_timestamp, :utc_datetime do + allow_nil? false + end + end + + relationships do + has_many :messages, AshHq.Discord.Message + + belongs_to :channel, AshHq.Discord.Channel do + attribute_type :integer + allow_nil? false + attribute_writable? true + end + + many_to_many :tags, AshHq.Discord.Tag do + through AshHq.Discord.ThreadTag + source_attribute_on_join_resource :thread_id + destination_attribute_on_join_resource :tag_id + end + end + + postgres do + table "discord_threads" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :upsert + define :by_id, action: :read, get_by: [:id] + define :feed, args: [:channel] + end +end diff --git a/lib/ash_hq/discord/resources/thread_tag.ex b/lib/ash_hq/discord/resources/thread_tag.ex new file mode 100644 index 00000000..14b9e6eb --- /dev/null +++ b/lib/ash_hq/discord/resources/thread_tag.ex @@ -0,0 +1,39 @@ +defmodule AshHq.Discord.ThreadTag do + @moduledoc "Joins a thread to a tag" + use Ash.Resource, + data_layer: AshPostgres.DataLayer + + actions do + defaults [:read, :destroy] + + create :tag do + upsert? true + end + end + + relationships do + belongs_to :thread, AshHq.Discord.Thread do + primary_key? true + allow_nil? false + attribute_writable? true + attribute_type :integer + end + + belongs_to :tag, AshHq.Discord.Tag do + primary_key? true + allow_nil? false + attribute_writable? true + attribute_type :integer + end + end + + postgres do + table "discord_thread_tags" + repo AshHq.Repo + end + + code_interface do + define_for AshHq.Discord + define :tag, args: [:thread_id, :tag_id] + end +end diff --git a/lib/ash_hq/discord/supervisor.ex b/lib/ash_hq/discord/supervisor.ex deleted file mode 100644 index ff22ad27..00000000 --- a/lib/ash_hq/discord/supervisor.ex +++ /dev/null @@ -1,15 +0,0 @@ -defmodule AshHq.Discord.Supervisor do - @moduledoc "Supervises the discord listener" - use Supervisor - - def start_link(args) do - Supervisor.start_link(__MODULE__, args, name: __MODULE__) - end - - @impl true - def init(_init_arg) do - children = [AshHq.Discord.Listener] - - Supervisor.init(children, strategy: :one_for_one) - end -end diff --git a/lib/ash_hq_web/pages/forum.ex b/lib/ash_hq_web/pages/forum.ex new file mode 100644 index 00000000..300c031c --- /dev/null +++ b/lib/ash_hq_web/pages/forum.ex @@ -0,0 +1,342 @@ +defmodule AshHqWeb.Pages.Forum do + @moduledoc "Forum page" + use Surface.LiveComponent + + require Ash.Query + + alias AshHqWeb.Components.Blog.Tag + alias AshHqWeb.Components.Forum.Attachment + alias Surface.Components.LivePatch + + import AshHqWeb.Tails + + prop(params, :map, default: %{}) + + data(thread, :any, default: nil) + data(threads, :any, default: []) + data(tag, :string, default: nil) + data(tags, :any, default: []) + data(channels, :any, default: []) + data(channel, :any, default: []) + + def render(assigns) do + ~F""" +
+ {message.author}: +
{raw(message.content_html)} + {#for attachment <- message.attachments} +