diff --git a/lux/guides/discord_core.md b/lux/guides/discord_core.md new file mode 100644 index 00000000..995d5a4b --- /dev/null +++ b/lux/guides/discord_core.md @@ -0,0 +1,73 @@ +# Discord Core Prisms Guide + +Core Discord server operations: messages, channels, moderation, events. + +## Message Management + +```elixir +# Send +{:ok, msg} = MessageManagement.send_message("channel_id", %{content: "Hello!", reply_to: "msg_id"}, opts) + +# Edit / Delete +MessageManagement.edit_message("ch", "msg_id", %{content: "Updated"}, opts) +MessageManagement.delete_message("ch", "msg_id", opts) + +# Bulk delete (2-100 messages) +MessageManagement.bulk_delete("ch", ["m1", "m2", "m3"], opts) + +# History, Pin, React +{:ok, messages} = MessageManagement.get_history("ch", %{limit: 50}, opts) +MessageManagement.pin_message("ch", "msg_id", opts) +MessageManagement.add_reaction("ch", "msg_id", "👍", opts) +``` + +## Channel Management + +```elixir +# CRUD +{:ok, ch} = ChannelManagement.create_channel("guild_id", %{name: "general", type: :text}, opts) +ChannelManagement.edit_channel("ch_id", %{name: "renamed", topic: "New topic"}, opts) +ChannelManagement.delete_channel("ch_id", opts) + +# Permissions +ChannelManagement.set_permission("ch_id", "role_id", %{type: 0, allow: 1024, deny: 0}, opts) + +# Archive/Unarchive +ChannelManagement.archive_channel("ch_id", opts) +``` + +## Moderation + +```elixir +{:ok, pid} = Moderation.start_link() + +# Content filtering (built-in: spam links, all caps) +{:ok, result} = Moderation.check_content(pid, "Join discord.gg/spam!") +# %{clean: false, violations: [%{action: :delete, severity: :high}]} + +Moderation.add_filter(pid, %{pattern: "badword", action: :ban, severity: :critical}) +Moderation.add_filter(pid, %{pattern: ~r/regex/i, action: :delete}) + +# Warnings +Moderation.warn_user(pid, "user_id", "Spamming") +{:ok, warnings} = Moderation.get_warnings(pid, "user_id") + +# Discord API actions +Moderation.timeout_user("guild", "user", 3600, opts) # 1h timeout +Moderation.ban_user("guild", "user", %{delete_message_seconds: 86400}, opts) +Moderation.kick_user("guild", "user", opts) +``` + +## Event Handling + +```elixir +{:ok, pid} = EventHandling.start_link() + +{:ok, event} = EventHandling.create_event(pid, %{ + name: "Game Night", start_time: ~U[2026-03-15 20:00:00Z], channel_id: "ch1" +}) + +EventHandling.add_reminder(pid, event.id, 30) # 30 min before +EventHandling.rsvp(pid, event.id, "user1", :going) +{:ok, due} = EventHandling.get_due_reminders(pid) +``` diff --git a/lux/lib/lux/prisms/discord/channel_management.ex b/lux/lib/lux/prisms/discord/channel_management.ex new file mode 100644 index 00000000..ece30138 --- /dev/null +++ b/lux/lib/lux/prisms/discord/channel_management.ex @@ -0,0 +1,76 @@ +defmodule Lux.Prisms.Discord.ChannelManagement do + @moduledoc """ + Channel management prism: CRUD, permissions, archiving. + """ + + alias Lux.Integrations.Discord.Client + + @channel_types %{text: 0, voice: 2, category: 4, announcement: 5, forum: 15, stage: 13} + + @doc "Create a channel in a guild." + def create_channel(guild_id, params, opts \\ %{}) do + body = %{ + name: params[:name], + type: Map.get(@channel_types, params[:type] || :text, 0) + } + |> maybe_put(:topic, params[:topic]) + |> maybe_put(:parent_id, params[:parent_id]) + |> maybe_put(:position, params[:position]) + |> maybe_put(:nsfw, params[:nsfw]) + |> maybe_put(:rate_limit_per_user, params[:slowmode]) + + Client.request(:post, "/guilds/#{guild_id}/channels", Map.merge(opts, %{json: body})) + end + + @doc "Edit a channel." + def edit_channel(channel_id, params, opts \\ %{}) do + body = %{} + |> maybe_put(:name, params[:name]) + |> maybe_put(:topic, params[:topic]) + |> maybe_put(:position, params[:position]) + |> maybe_put(:nsfw, params[:nsfw]) + |> maybe_put(:rate_limit_per_user, params[:slowmode]) + |> maybe_put(:parent_id, params[:parent_id]) + + Client.request(:patch, "/channels/#{channel_id}", Map.merge(opts, %{json: body})) + end + + @doc "Delete a channel." + def delete_channel(channel_id, opts \\ %{}) do + Client.request(:delete, "/channels/#{channel_id}", opts) + end + + @doc "Get channel info." + def get_channel(channel_id, opts \\ %{}) do + Client.request(:get, "/channels/#{channel_id}", opts) + end + + @doc "Set permission overwrite for a channel." + def set_permission(channel_id, overwrite_id, params, opts \\ %{}) do + body = %{ + id: overwrite_id, + type: params[:type] || 0, + allow: to_string(params[:allow] || 0), + deny: to_string(params[:deny] || 0) + } + Client.request(:put, "/channels/#{channel_id}/permissions/#{overwrite_id}", Map.merge(opts, %{json: body})) + end + + @doc "Archive a channel (set archived flag for threads/forums)." + def archive_channel(channel_id, opts \\ %{}) do + Client.request(:patch, "/channels/#{channel_id}", Map.merge(opts, %{json: %{archived: true}})) + end + + @doc "Unarchive a channel." + def unarchive_channel(channel_id, opts \\ %{}) do + Client.request(:patch, "/channels/#{channel_id}", Map.merge(opts, %{json: %{archived: false}})) + end + + @doc "List guild channels." + def list_channels(guild_id, opts \\ %{}) do + Client.request(:get, "/guilds/#{guild_id}/channels", opts) + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/lux/lib/lux/prisms/discord/event_handling.ex b/lux/lib/lux/prisms/discord/event_handling.ex new file mode 100644 index 00000000..829d4143 --- /dev/null +++ b/lux/lib/lux/prisms/discord/event_handling.ex @@ -0,0 +1,124 @@ +defmodule Lux.Prisms.Discord.EventHandling do + @moduledoc """ + Event scheduling, reminders, and notification management for Discord. + """ + + use GenServer + + defstruct [:events, :reminders, :notifications] + + def start_link(opts \\ []) do + name = opts[:name] || __MODULE__ + GenServer.start_link(__MODULE__, opts, name: name) + end + + def create_event(pid \\ __MODULE__, params) do + GenServer.call(pid, {:create_event, params}) + end + + def cancel_event(pid \\ __MODULE__, event_id) do + GenServer.call(pid, {:cancel_event, event_id}) + end + + def list_events(pid \\ __MODULE__, opts \\ %{}) do + GenServer.call(pid, {:list_events, opts}) + end + + def get_event(pid \\ __MODULE__, event_id) do + GenServer.call(pid, {:get_event, event_id}) + end + + def add_reminder(pid \\ __MODULE__, event_id, minutes_before) do + GenServer.call(pid, {:add_reminder, event_id, minutes_before}) + end + + def get_due_reminders(pid \\ __MODULE__) do + GenServer.call(pid, :get_due_reminders) + end + + def rsvp(pid \\ __MODULE__, event_id, user_id, status) do + GenServer.call(pid, {:rsvp, event_id, user_id, status}) + end + + @impl true + def init(_opts) do + {:ok, %__MODULE__{events: %{}, reminders: [], notifications: []}} + end + + @impl true + def handle_call({:create_event, params}, _from, state) do + id = params[:id] || gen_id() + event = %{ + id: id, + name: params[:name], + description: params[:description] || "", + channel_id: params[:channel_id], + guild_id: params[:guild_id], + start_time: params[:start_time], + end_time: params[:end_time], + rsvps: %{}, + status: :scheduled, + created_at: DateTime.utc_now() + } + {:reply, {:ok, event}, %{state | events: Map.put(state.events, id, event)}} + end + + @impl true + def handle_call({:cancel_event, event_id}, _from, state) do + case Map.get(state.events, event_id) do + nil -> {:reply, {:error, :not_found}, state} + event -> + updated = %{event | status: :cancelled} + {:reply, :ok, %{state | events: Map.put(state.events, event_id, updated)}} + end + end + + @impl true + def handle_call({:list_events, opts}, _from, state) do + events = Map.values(state.events) + events = if opts[:status], do: Enum.filter(events, &(&1.status == opts[:status])), else: events + {:reply, {:ok, Enum.sort_by(events, & &1.start_time, DateTime)}, state} + end + + @impl true + def handle_call({:get_event, event_id}, _from, state) do + case Map.get(state.events, event_id) do + nil -> {:reply, {:error, :not_found}, state} + event -> {:reply, {:ok, event}, state} + end + end + + @impl true + def handle_call({:add_reminder, event_id, minutes_before}, _from, state) do + case Map.get(state.events, event_id) do + nil -> {:reply, {:error, :not_found}, state} + event -> + remind_at = DateTime.add(event.start_time, -minutes_before * 60) + reminder = %{event_id: event_id, remind_at: remind_at, sent: false} + {:reply, {:ok, reminder}, %{state | reminders: [reminder | state.reminders]}} + end + end + + @impl true + def handle_call(:get_due_reminders, _from, state) do + now = DateTime.utc_now() + {due, remaining} = Enum.split_with(state.reminders, fn r -> + !r.sent && DateTime.compare(r.remind_at, now) in [:lt, :eq] + end) + marked = Enum.map(due, &%{&1 | sent: true}) + {:reply, {:ok, due}, %{state | reminders: marked ++ remaining}} + end + + @impl true + def handle_call({:rsvp, event_id, user_id, status}, _from, state) do + case Map.get(state.events, event_id) do + nil -> {:reply, {:error, :not_found}, state} + event -> + updated = %{event | rsvps: Map.put(event.rsvps, user_id, status)} + {:reply, {:ok, %{user_id: user_id, status: status}}, + %{state | events: Map.put(state.events, event_id, updated)}} + end + end + + defp gen_id, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) +end diff --git a/lux/lib/lux/prisms/discord/message_management.ex b/lux/lib/lux/prisms/discord/message_management.ex new file mode 100644 index 00000000..563fb620 --- /dev/null +++ b/lux/lib/lux/prisms/discord/message_management.ex @@ -0,0 +1,91 @@ +defmodule Lux.Prisms.Discord.MessageManagement do + @moduledoc """ + Message management prism for Discord: create, edit, delete, bulk delete, history. + Includes rate limit tracking and retry mechanisms. + """ + + alias Lux.Integrations.Discord.Client + + @max_bulk_delete 100 + @max_retries 3 + + @doc "Send a message to a channel." + def send_message(channel_id, params, opts \\ %{}) do + body = %{content: params[:content]} + |> maybe_put(:embeds, params[:embeds]) + |> maybe_put(:components, params[:components]) + |> maybe_put(:message_reference, if(params[:reply_to], do: %{message_id: params[:reply_to]})) + + with_retry(fn -> + Client.request(:post, "/channels/#{channel_id}/messages", Map.merge(opts, %{json: body})) + end) + end + + @doc "Edit an existing message." + def edit_message(channel_id, message_id, params, opts \\ %{}) do + body = %{} + |> maybe_put(:content, params[:content]) + |> maybe_put(:embeds, params[:embeds]) + + with_retry(fn -> + Client.request(:patch, "/channels/#{channel_id}/messages/#{message_id}", Map.merge(opts, %{json: body})) + end) + end + + @doc "Delete a message." + def delete_message(channel_id, message_id, opts \\ %{}) do + with_retry(fn -> + Client.request(:delete, "/channels/#{channel_id}/messages/#{message_id}", opts) + end) + end + + @doc "Bulk delete messages (2-100 messages, not older than 14 days)." + def bulk_delete(channel_id, message_ids, opts \\ %{}) when length(message_ids) <= @max_bulk_delete do + with_retry(fn -> + Client.request(:post, "/channels/#{channel_id}/messages/bulk-delete", + Map.merge(opts, %{json: %{messages: message_ids}})) + end) + end + + @doc "Get message history for a channel." + def get_history(channel_id, params \\ %{}, opts \\ %{}) do + query = %{} + |> maybe_put(:limit, params[:limit] || 50) + |> maybe_put(:before, params[:before]) + |> maybe_put(:after, params[:after]) + |> maybe_put(:around, params[:around]) + + query_string = query |> Enum.reject(fn {_, v} -> is_nil(v) end) |> URI.encode_query() + path = "/channels/#{channel_id}/messages?#{query_string}" + + with_retry(fn -> Client.request(:get, path, opts) end) + end + + @doc "Pin a message." + def pin_message(channel_id, message_id, opts \\ %{}) do + with_retry(fn -> + Client.request(:put, "/channels/#{channel_id}/pins/#{message_id}", opts) + end) + end + + @doc "Add a reaction to a message." + def add_reaction(channel_id, message_id, emoji, opts \\ %{}) do + encoded = URI.encode(emoji) + with_retry(fn -> + Client.request(:put, "/channels/#{channel_id}/messages/#{message_id}/reactions/#{encoded}/@me", opts) + end) + end + + defp with_retry(fun, attempt \\ 1) do + case fun.() do + {:error, %{status: 429} = resp} when attempt < @max_retries -> + retry_after = get_in(resp, [:body, "retry_after"]) || 1 + Process.sleep(trunc(retry_after * 1000)) + with_retry(fun, attempt + 1) + result -> result + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) +end diff --git a/lux/lib/lux/prisms/discord/moderation.ex b/lux/lib/lux/prisms/discord/moderation.ex new file mode 100644 index 00000000..e8730947 --- /dev/null +++ b/lux/lib/lux/prisms/discord/moderation.ex @@ -0,0 +1,149 @@ +defmodule Lux.Prisms.Discord.Moderation do + @moduledoc """ + Moderation prism: content filtering, timeouts, bans, warnings. + """ + + alias Lux.Integrations.Discord.Client + + use GenServer + + defstruct [:filters, :warnings, :action_log] + + def start_link(opts \\ []) do + name = opts[:name] || __MODULE__ + GenServer.start_link(__MODULE__, opts, name: name) + end + + # Content filtering + + def add_filter(pid \\ __MODULE__, filter) do + GenServer.call(pid, {:add_filter, filter}) + end + + def check_content(pid \\ __MODULE__, text) do + GenServer.call(pid, {:check_content, text}) + end + + def list_filters(pid \\ __MODULE__) do + GenServer.call(pid, :list_filters) + end + + # Discord API moderation + + def timeout_user(guild_id, user_id, duration_seconds, opts \\ %{}) do + until = DateTime.utc_now() |> DateTime.add(duration_seconds) |> DateTime.to_iso8601() + Client.request(:patch, "/guilds/#{guild_id}/members/#{user_id}", + Map.merge(opts, %{json: %{communication_disabled_until: until}})) + end + + def remove_timeout(guild_id, user_id, opts \\ %{}) do + Client.request(:patch, "/guilds/#{guild_id}/members/#{user_id}", + Map.merge(opts, %{json: %{communication_disabled_until: nil}})) + end + + def ban_user(guild_id, user_id, params \\ %{}, opts \\ %{}) do + body = %{} + |> maybe_put(:delete_message_seconds, params[:delete_message_seconds]) + + Client.request(:put, "/guilds/#{guild_id}/bans/#{user_id}", Map.merge(opts, %{json: body})) + end + + def unban_user(guild_id, user_id, opts \\ %{}) do + Client.request(:delete, "/guilds/#{guild_id}/bans/#{user_id}", opts) + end + + def kick_user(guild_id, user_id, opts \\ %{}) do + Client.request(:delete, "/guilds/#{guild_id}/members/#{user_id}", opts) + end + + def warn_user(pid \\ __MODULE__, user_id, reason) do + GenServer.call(pid, {:warn, user_id, reason}) + end + + def get_warnings(pid \\ __MODULE__, user_id) do + GenServer.call(pid, {:get_warnings, user_id}) + end + + def get_action_log(pid \\ __MODULE__) do + GenServer.call(pid, :get_action_log) + end + + # Server + + @impl true + def init(_opts) do + {:ok, %__MODULE__{ + filters: default_filters(), + warnings: %{}, + action_log: [] + }} + end + + @impl true + def handle_call({:add_filter, filter}, _from, state) do + entry = %{ + id: gen_id(), + pattern: filter[:pattern], + action: filter[:action] || :delete, + severity: filter[:severity] || :medium + } + {:reply, {:ok, entry}, %{state | filters: [entry | state.filters]}} + end + + @impl true + def handle_call({:check_content, text}, _from, state) do + violations = state.filters + |> Enum.filter(fn filter -> + case filter.pattern do + %Regex{} = r -> Regex.match?(r, text) + s when is_binary(s) -> String.contains?(String.downcase(text), String.downcase(s)) + _ -> false + end + end) + + result = if violations == [] do + %{clean: true, violations: []} + else + %{clean: false, violations: Enum.map(violations, &Map.take(&1, [:id, :action, :severity]))} + end + + {:reply, {:ok, result}, state} + end + + @impl true + def handle_call(:list_filters, _from, state) do + {:reply, {:ok, state.filters}, state} + end + + @impl true + def handle_call({:warn, user_id, reason}, _from, state) do + warning = %{reason: reason, at: DateTime.utc_now()} + warnings = Map.update(state.warnings, user_id, [warning], &[warning | &1]) + count = length(Map.get(warnings, user_id)) + log = [%{action: :warn, user_id: user_id, reason: reason, at: DateTime.utc_now()} | state.action_log] + {:reply, {:ok, %{user_id: user_id, warning_count: count, reason: reason}}, + %{state | warnings: warnings, action_log: log}} + end + + @impl true + def handle_call({:get_warnings, user_id}, _from, state) do + {:reply, {:ok, Map.get(state.warnings, user_id, [])}, state} + end + + @impl true + def handle_call(:get_action_log, _from, state) do + {:reply, {:ok, state.action_log}, state} + end + + defp default_filters do + [ + %{id: "spam_links", pattern: ~r/discord\.gg\/\w+/i, action: :delete, severity: :high}, + %{id: "all_caps", pattern: ~r/^[A-Z\s!]{20,}$/, action: :warn, severity: :low} + ] + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, value) + + defp gen_id, do: :crypto.strong_rand_bytes(4) |> Base.encode16(case: :lower) +end diff --git a/lux/test/unit/lux/prisms/discord/channel_management_test.exs b/lux/test/unit/lux/prisms/discord/channel_management_test.exs new file mode 100644 index 00000000..7124cc7d --- /dev/null +++ b/lux/test/unit/lux/prisms/discord/channel_management_test.exs @@ -0,0 +1,54 @@ +defmodule Lux.Prisms.Discord.ChannelManagementTest do + use ExUnit.Case, async: true + alias Lux.Prisms.Discord.ChannelManagement + + setup do + Req.Test.stub(Lux.Integrations.Discord.Client, fn conn -> + case conn.method do + "POST" -> Req.Test.json(conn, %{"id" => "ch_new", "name" => "test", "type" => 0}) + "PATCH" -> Req.Test.json(conn, %{"id" => "ch_1", "name" => "edited"}) + "DELETE" -> Req.Test.json(conn, %{}) + "GET" -> Req.Test.json(conn, [%{"id" => "ch_1"}, %{"id" => "ch_2"}]) + "PUT" -> Req.Test.json(conn, %{}) + end + end) + :ok + end + + @opts %{plug: {Req.Test, Lux.Integrations.Discord.Client}} + + test "create channel" do + {:ok, body} = ChannelManagement.create_channel("g1", %{name: "test", type: :text}, @opts) + assert body["id"] == "ch_new" + end + + test "edit channel" do + {:ok, body} = ChannelManagement.edit_channel("ch_1", %{name: "edited"}, @opts) + assert body["name"] == "edited" + end + + test "delete channel" do + {:ok, _} = ChannelManagement.delete_channel("ch_1", @opts) + end + + test "get channel" do + {:ok, _} = ChannelManagement.get_channel("ch_1", @opts) + end + + test "list channels" do + {:ok, body} = ChannelManagement.list_channels("g1", @opts) + assert length(body) == 2 + end + + test "set permission" do + {:ok, _} = ChannelManagement.set_permission("ch_1", "role_1", %{type: 0, allow: 1024, deny: 0}, @opts) + end + + test "archive channel" do + {:ok, _} = ChannelManagement.archive_channel("ch_1", @opts) + end + + test "unarchive channel" do + {:ok, _} = ChannelManagement.unarchive_channel("ch_1", @opts) + end +end diff --git a/lux/test/unit/lux/prisms/discord/event_handling_test.exs b/lux/test/unit/lux/prisms/discord/event_handling_test.exs new file mode 100644 index 00000000..6efe5f96 --- /dev/null +++ b/lux/test/unit/lux/prisms/discord/event_handling_test.exs @@ -0,0 +1,70 @@ +defmodule Lux.Prisms.Discord.EventHandlingTest do + use ExUnit.Case, async: true + alias Lux.Prisms.Discord.EventHandling + + setup do + name = :"event_#{:rand.uniform(1_000_000)}" + {:ok, pid} = EventHandling.start_link(name: name) + %{pid: pid} + end + + test "create event", %{pid: pid} do + {:ok, event} = EventHandling.create_event(pid, %{ + name: "Game Night", + start_time: DateTime.utc_now() |> DateTime.add(86400), + channel_id: "ch1" + }) + assert event.name == "Game Night" + assert event.status == :scheduled + end + + test "cancel event", %{pid: pid} do + {:ok, event} = EventHandling.create_event(pid, %{name: "Cancel me", start_time: DateTime.utc_now()}) + :ok = EventHandling.cancel_event(pid, event.id) + {:ok, found} = EventHandling.get_event(pid, event.id) + assert found.status == :cancelled + end + + test "list events", %{pid: pid} do + {:ok, _} = EventHandling.create_event(pid, %{name: "E1", start_time: DateTime.utc_now() |> DateTime.add(3600)}) + {:ok, _} = EventHandling.create_event(pid, %{name: "E2", start_time: DateTime.utc_now() |> DateTime.add(7200)}) + {:ok, events} = EventHandling.list_events(pid) + assert length(events) == 2 + end + + test "list by status", %{pid: pid} do + {:ok, e} = EventHandling.create_event(pid, %{name: "E1", start_time: DateTime.utc_now()}) + {:ok, _} = EventHandling.create_event(pid, %{name: "E2", start_time: DateTime.utc_now()}) + EventHandling.cancel_event(pid, e.id) + {:ok, scheduled} = EventHandling.list_events(pid, %{status: :scheduled}) + assert length(scheduled) == 1 + end + + test "add reminder", %{pid: pid} do + future = DateTime.utc_now() |> DateTime.add(7200) + {:ok, event} = EventHandling.create_event(pid, %{name: "E1", start_time: future}) + {:ok, reminder} = EventHandling.add_reminder(pid, event.id, 30) + assert reminder.event_id == event.id + end + + test "get due reminders", %{pid: pid} do + past = DateTime.utc_now() |> DateTime.add(60) + {:ok, event} = EventHandling.create_event(pid, %{name: "Soon", start_time: past}) + {:ok, _} = EventHandling.add_reminder(pid, event.id, 5) # remind 5 min before (already due) + {:ok, due} = EventHandling.get_due_reminders(pid) + assert length(due) >= 1 + end + + test "rsvp", %{pid: pid} do + {:ok, event} = EventHandling.create_event(pid, %{name: "Party", start_time: DateTime.utc_now()}) + {:ok, rsvp} = EventHandling.rsvp(pid, event.id, "user1", :going) + assert rsvp.status == :going + {:ok, found} = EventHandling.get_event(pid, event.id) + assert found.rsvps["user1"] == :going + end + + test "not found event", %{pid: pid} do + assert {:error, :not_found} = EventHandling.get_event(pid, "nope") + assert {:error, :not_found} = EventHandling.cancel_event(pid, "nope") + end +end diff --git a/lux/test/unit/lux/prisms/discord/message_management_test.exs b/lux/test/unit/lux/prisms/discord/message_management_test.exs new file mode 100644 index 00000000..103ac791 --- /dev/null +++ b/lux/test/unit/lux/prisms/discord/message_management_test.exs @@ -0,0 +1,60 @@ +defmodule Lux.Prisms.Discord.MessageManagementTest do + use ExUnit.Case, async: true + alias Lux.Prisms.Discord.MessageManagement + + setup do + Req.Test.stub(Lux.Integrations.Discord.Client, fn conn -> + case {conn.method, conn.request_path} do + {"POST", "/api/v10/channels/" <> _} -> + Req.Test.json(conn, %{"id" => "msg_123", "content" => "hello"}) + {"PATCH", "/api/v10/channels/" <> _} -> + Req.Test.json(conn, %{"id" => "msg_123", "content" => "edited"}) + {"DELETE", "/api/v10/channels/" <> _} -> + Req.Test.json(conn, %{}) + {"GET", "/api/v10/channels/" <> _} -> + Req.Test.json(conn, [%{"id" => "msg_1"}, %{"id" => "msg_2"}]) + {"PUT", "/api/v10/channels/" <> _} -> + Req.Test.json(conn, %{}) + end + end) + :ok + end + + @opts %{plug: {Req.Test, Lux.Integrations.Discord.Client}} + + test "send message" do + {:ok, body} = MessageManagement.send_message("ch1", %{content: "hello"}, @opts) + assert body["id"] == "msg_123" + end + + test "edit message" do + {:ok, body} = MessageManagement.edit_message("ch1", "msg_123", %{content: "edited"}, @opts) + assert body["content"] == "edited" + end + + test "delete message" do + {:ok, _} = MessageManagement.delete_message("ch1", "msg_123", @opts) + end + + test "get history" do + {:ok, body} = MessageManagement.get_history("ch1", %{limit: 10}, @opts) + assert length(body) == 2 + end + + test "pin message" do + {:ok, _} = MessageManagement.pin_message("ch1", "msg_123", @opts) + end + + test "add reaction" do + {:ok, _} = MessageManagement.add_reaction("ch1", "msg_123", "👍", @opts) + end + + test "bulk delete" do + {:ok, _} = MessageManagement.bulk_delete("ch1", ["m1", "m2"], @opts) + end + + test "send with reply_to" do + {:ok, body} = MessageManagement.send_message("ch1", %{content: "reply", reply_to: "orig"}, @opts) + assert body["id"] + end +end diff --git a/lux/test/unit/lux/prisms/discord/moderation_test.exs b/lux/test/unit/lux/prisms/discord/moderation_test.exs new file mode 100644 index 00000000..b4de5d7f --- /dev/null +++ b/lux/test/unit/lux/prisms/discord/moderation_test.exs @@ -0,0 +1,64 @@ +defmodule Lux.Prisms.Discord.ModerationTest do + use ExUnit.Case, async: true + alias Lux.Prisms.Discord.Moderation + + setup do + name = :"mod_#{:rand.uniform(1_000_000)}" + {:ok, pid} = Moderation.start_link(name: name) + %{pid: pid} + end + + test "check clean content", %{pid: pid} do + {:ok, result} = Moderation.check_content(pid, "Hello, how are you?") + assert result.clean + end + + test "detect spam link", %{pid: pid} do + {:ok, result} = Moderation.check_content(pid, "Join my server discord.gg/abc123") + refute result.clean + assert length(result.violations) >= 1 + end + + test "add custom filter", %{pid: pid} do + {:ok, filter} = Moderation.add_filter(pid, %{pattern: "badword", action: :delete, severity: :high}) + assert filter.action == :delete + {:ok, result} = Moderation.check_content(pid, "This contains badword in it") + refute result.clean + end + + test "list filters", %{pid: pid} do + {:ok, filters} = Moderation.list_filters(pid) + assert length(filters) >= 2 # default filters + end + + test "warn user", %{pid: pid} do + {:ok, result} = Moderation.warn_user(pid, "user1", "Spamming") + assert result.warning_count == 1 + {:ok, result2} = Moderation.warn_user(pid, "user1", "Toxicity") + assert result2.warning_count == 2 + end + + test "get warnings", %{pid: pid} do + Moderation.warn_user(pid, "user1", "Test") + {:ok, warnings} = Moderation.get_warnings(pid, "user1") + assert length(warnings) == 1 + end + + test "get empty warnings", %{pid: pid} do + {:ok, warnings} = Moderation.get_warnings(pid, "unknown") + assert warnings == [] + end + + test "action log", %{pid: pid} do + Moderation.warn_user(pid, "user1", "Reason") + {:ok, log} = Moderation.get_action_log(pid) + assert length(log) == 1 + assert hd(log).action == :warn + end + + test "regex filter", %{pid: pid} do + {:ok, _} = Moderation.add_filter(pid, %{pattern: ~r/n[i1]gg/i, action: :ban, severity: :critical}) + {:ok, result} = Moderation.check_content(pid, "Some slur n1gg here") + refute result.clean + end +end