diff --git a/bench/FINDINGS.txt b/bench/FINDINGS.txt index 93059d1..ecca990 100644 --- a/bench/FINDINGS.txt +++ b/bench/FINDINGS.txt @@ -37,3 +37,11 @@ averaging 210 words per record STRING.SPLIT IDENTICAL TO :binary.split() GIVING STRING.SPLIT A PRECOMPILED PATTERN HELPS THE SPEED + +BINARY MATCHING FROM PREMADE LIST IS FASTER THAN REGEX +##### With input 128 prefixed strings ##### +Name ips average deviation median 99th % +Single-case Binary match split 79.90 K 12.52 μs ±408.49% 9.52 μs 27.70 μs +Binary match split 25.46 K 39.28 μs ±177.42% 32.96 μs 143.95 μs +Single-case Regex split 5.94 K 168.27 μs ±213.75% 152.29 μs 198.49 μs +Regex split 3.32 K 300.76 μs ±76.96% 286.42 μs 340.08 μs diff --git a/bench/prefix_checking.ex b/bench/prefix_checking.ex new file mode 100644 index 0000000..c33b2fe --- /dev/null +++ b/bench/prefix_checking.ex @@ -0,0 +1,87 @@ +alias Stampede, as: S +require Stampede.MsgReceived +require Aja + +defmodule T do + require Plugin + require Aja + + def split_prefix_re(text, prefix) when is_struct(prefix, Regex) and is_binary(text) do + case Regex.split(prefix, text, include_captures: true, capture: :first, trim: true) do + [p, b] -> + {p, b} + + [^text] -> + {false, text} + + [] -> + {false, text} + end + end + + def split_prefix(text, prefix) when is_binary(prefix) and is_binary(text) do + case text do + <<^prefix::binary-size(floor(bit_size(prefix) / 8)), _::binary>> -> + { + binary_part(text, 0, byte_size(prefix)), + binary_part(text, byte_size(prefix), byte_size(text) - byte_size(prefix)) + } + + not_prefixed -> + {false, not_prefixed} + end + end + + def split_prefix(text, prefixes) when is_list(prefixes) and is_binary(text) do + prefixes + |> Enum.reduce(nil, fn + _, {s, b} -> + {s, b} + + p, nil -> + {s, b} = split_prefix(text, p) + if s, do: {s, b}, else: nil + end) + |> then(fn + nil -> + {false, text} + + {s, b} -> + {s, b} + end) + end + + def make_input(pref, number) do + for n <- 0..(number - 1) do + Enum.at(pref, Integer.mod(n, length(pref))) <> String.duplicate("x", 16) + end + end +end + +r = ~r/^[ab][cd]/ +bl = ["ac", "bc", "ad", "bd"] +r2 = ~r/\!/ +bl2 = ["!"] + +inputs = %{ + "128 prefixed strings" => T.make_input(bl, 128), + "128 non-prefixed" => Enum.map(0..127, fn _ -> String.duplicate("x", 18) end) +} + +suites = %{ + "Regex split" => &Enum.map(&1, fn x -> T.split_prefix_re(x, r) end), + "Binary match split" => &Enum.map(&1, fn x -> T.split_prefix(x, bl) end), + "Single-case Regex split" => &Enum.map(&1, fn x -> T.split_prefix_re(x, r2) end), + "Single-case Binary match split" => &Enum.map(&1, fn x -> T.split_prefix(x, bl2) end) +} + +Benchee.run( + suites, + inputs: inputs, + time: 30, + memory_time: 5, + # profile_after: true, + # profile_after: :fprof + measure_function_call_overhead: true, + pre_check: true +) diff --git a/config/config.exs b/config/config.exs index bb7a9af..e26e794 100644 --- a/config/config.exs +++ b/config/config.exs @@ -41,6 +41,7 @@ config :logger, :console, metadata: stampede_metadata ++ [:mfa] config :logger, + truncate: :infinity, handle_otp_reports: true, # this will spam a lot of messages handle_sasl_reports: false, diff --git a/lib/plugin.ex b/lib/plugin.ex index 2448bc3..7cf76ce 100644 --- a/lib/plugin.ex +++ b/lib/plugin.ex @@ -50,6 +50,7 @@ defmodule Plugin do @callback usage() :: usage_tuples() @callback description() :: TxtBlock.t() + @callback description_long() :: TxtBlock.t() defguard is_bot_invoked(msg) when msg.at_bot? or msg.dm? or msg.prefix != false @@ -59,7 +60,12 @@ defmodule Plugin do defmacro __using__(_opts \\ []) do quote do - @behaviour unquote(__MODULE__) + @behaviour Plugin + + @impl Plugin + def description_long(), do: description() + + defoverridable description_long: 0 end end @@ -77,11 +83,7 @@ defmodule Plugin do S.find_submodules(Plugins) |> Enum.reduce(MapSet.new(), fn mod, acc -> - b = - mod.__info__(:attributes) - |> Keyword.get(:behaviour, []) - - if Plugin in b do + if valid?(mod) do MapSet.put(acc, mod) else acc @@ -523,4 +525,22 @@ defmodule Plugin do ) ) end + + @spec! decorate_usage(SiteConfig.t(), module()) :: TxtBlock.t() + def decorate_usage(cfg, plugin) do + {{:list, :dotted}, + Enum.map(plugin.usage(), fn + {prompt, response} -> + [ + {:source, SiteConfig.example_prefix(cfg) <> prompt}, + " ", + {:bold, "<>"}, + " ", + {:source, response} + ] + + other -> + TypeCheck.conforms!(other, TxtBlock.t()) + end)} + end end diff --git a/lib/plugins/help.ex b/lib/plugins/help.ex new file mode 100644 index 0000000..1903f00 --- /dev/null +++ b/lib/plugins/help.ex @@ -0,0 +1,110 @@ +defmodule Plugins.Help do + @moduledoc false + require Logger + use TypeCheck + alias Stampede, as: S + require S.ResponseToPost + use Plugin + + # TODO: make all except ping only respond to admins + + @impl Plugin + def usage() do + [ + {"help", "(main help)"}, + {"help [plugin]", "(describes plugin)"} + ] + end + + @impl Plugin + def description() do + "Describes how the bot can be used. You're using it right now!" + end + + @impl Plugin + @spec! respond(SiteConfig.t(), S.MsgReceived.t()) :: nil | S.ResponseToPost.t() + def respond(_cfg, msg) when not Plugin.is_bot_invoked(msg), do: nil + + def respond(cfg, msg) when Plugin.is_bot_invoked(msg) do + plugs = + SiteConfig.get_plugs(cfg) + + case summon_type(msg.body) do + :list_plugins -> + txt = + [ + "Here are the available plugins! Learn about any of them with ", + {:source, "help [plugin]"}, + "\n\n", + {{:list, :dotted}, + plugs + |> Enum.map(fn + plug -> + s = SiteConfig.trim_plugin_name(plug) + + [ + {:bold, s}, + ": ", + plug.description() + ] + |> List.flatten() + end)} + ] + + S.ResponseToPost.new( + confidence: 10, + text: txt, + origin_msg_id: msg.id, + why: ["They pinged so I ponged!"] + ) + + {:specific, requested_name} -> + downcase = requested_name |> String.downcase() + + Enum.find(plugs, nil, fn full_atom -> + downcase == full_atom |> SiteConfig.trim_plugin_name() |> String.downcase() + end) + |> case do + nil -> + S.ResponseToPost.new( + confidence: 10, + text: [ + "Couldn't find a module named #{requested_name}. Possible modules: ", + Enum.map(Plugin.ls(), &inspect/1) |> Enum.intersperse(", ") + ], + origin_msg_id: msg.id, + why: ["They asked for a module that didn't exist."] + ) + + found -> + S.ResponseToPost.new( + confidence: 10, + text: [ + found.description_long(), + "\n\nUsage:\n", + Plugin.decorate_usage(cfg, found) + ], + origin_msg_id: msg.id, + why: ["They asked for help with a module."] + ) + end + + nil -> + nil + end + end + + def summon_type(body) do + if Regex.match?(~r/^(help$|list plugin(s)?)/, body) do + :list_plugins + else + case Regex.run(~r/^help (\w+)/, body, capture: :all_but_first) do + [plug] -> + {:specific, plug} + + nil -> + nil + end + end + end +end diff --git a/lib/plugins/why.ex b/lib/plugins/why.ex index b8ae2bb..4873a07 100644 --- a/lib/plugins/why.ex +++ b/lib/plugins/why.ex @@ -29,11 +29,17 @@ defmodule Plugins.Why do def description() do """ Explains the bot's reasoning for posting a particular message, if it remembers it. Summoned with "why did you say that?" for a short summary. Remember to identify the message you want; on Discord, this is the "reply" function. If you want a full traceback, ask with "specifically". - - Full regex: #{at_module_regex() |> Regex.source()} """ end + def description_long() do + [ + description(), + "Full regex: ", + {:source, at_module_regex() |> Regex.source()} + ] + end + @impl Plugin @spec! respond(SiteConfig.t(), S.MsgReceived.t()) :: nil | S.ResponseToPost.t() def respond(_cfg, msg) when not Plugin.is_bot_invoked(msg), do: nil diff --git a/lib/service/discord.ex b/lib/service/discord.ex index affd1b7..5803bf2 100644 --- a/lib/service/discord.ex +++ b/lib/service/discord.ex @@ -183,7 +183,8 @@ defmodule Service.Discord do " in plugin ", inspect(p), ":\n\n", - {:source_block, [S.pp(e), "\n", S.pp(st)]} + {:source_block, [Exception.format(t, e, st)]} + # BUG: can't handle colored text like TypeCheck failures ] end diff --git a/lib/service/dummy.ex b/lib/service/dummy.ex index ce1c1e8..b8aa10c 100644 --- a/lib/service/dummy.ex +++ b/lib/service/dummy.ex @@ -94,10 +94,6 @@ defmodule Service.Dummy do default: :error, type: :atom ], - prefix: [ - default: "!", - type: S.ntc(Regex.t() | String.t()) - ], plugs: [ default: ["Test", "Sentience"], type: {:custom, SiteConfig, :real_plugins, []} @@ -197,7 +193,8 @@ defmodule Service.Dummy do " in plugin ", inspect(p), ":\n\n", - {:source_block, [S.pp(e), "\n", S.pp(st)]} + {:source_block, [Exception.format(t, e, st)]} + # BUG: can't handle colored text like TypeCheck failures ] end diff --git a/lib/site_config.ex b/lib/site_config.ex index 83fdb08..ef60b15 100644 --- a/lib/site_config.ex +++ b/lib/site_config.ex @@ -53,7 +53,7 @@ defmodule SiteConfig do ], prefix: [ default: "!", - type: S.ntc(Regex.t() | String.t()), + type: {:or, [:string, {:list, :string}]}, doc: "What prefix should users put on messages to have them responded to?" ], plugs: [ @@ -70,6 +70,11 @@ defmodule SiteConfig do default: false, type: :boolean, doc: "Can this bot send messages when not explicitly tagged?" + ], + filename: [ + type: :string, + required: false, + doc: "File this config was loaded from" ] ] @mapset_keys [:vip_ids] @@ -95,6 +100,17 @@ defmodule SiteConfig do def fetch!(cfg, key) when is_map_key(cfg, key), do: Map.fetch!(cfg, key) + @doc "return all plugs that this site expects" + def get_plugs(:all), do: Plugin.ls() + + def get_plugs(cfg) when not is_struct(cfg, MapSet), + do: fetch!(cfg, :plugs) |> get_plugs() + + def get_plugs(plugs) do + {:ok, plugs} = real_plugins(plugs) + plugs + end + @doc "Verify that explicitly listed plugins actually exist" def real_plugins(:all), do: {:ok, :all} @@ -121,7 +137,6 @@ defmodule SiteConfig do transforms = [ &concat_plugs/2, - &make_regex/2, make_mapsets(@mapset_keys), fn kwlist, _ -> Keyword.update!(kwlist, :service, &S.service_atom_to_name(&1)) @@ -167,26 +182,8 @@ defmodule SiteConfig do end end - @doc "If prefix describes a Regex, compile it" - def make_regex(kwlist, _schema) do - if Keyword.has_key?(kwlist, :prefix) do - Keyword.update!(kwlist, :prefix, fn - prefix -> - case S.split_prefix(prefix, "~r/") do - {"~r/", rex} -> - Regex.compile!(rex) - - {false, otherwise} -> - otherwise - end - end) - else - kwlist - end - end - @doc "For the given keys, make a function that will replace the enumerables at those keys with MapSets" - @spec! make_mapsets(list(atom())) :: (keyword(), any() -> keyword()) + @spec! make_mapsets(list(atom()) | %MapSet{}) :: (keyword(), any() -> keyword()) def make_mapsets(keys) do fn kwlist, _schema -> Enum.reduce(keys, kwlist, fn key, acc -> @@ -196,6 +193,9 @@ defmodule SiteConfig do enum when is_list(enum) or enum == [] -> Keyword.update!(acc, key, fn enum -> MapSet.new(enum) end) + + ms when is_struct(ms, MapSet) -> + acc end end) end @@ -226,7 +226,7 @@ defmodule SiteConfig do Path.wildcard(target_dir <> "/*") |> Enum.reduce(Map.new(), fn path, service_map -> - site_name = String.to_atom(Path.basename(path, ".yml")) + site_name = Path.basename(path, ".yml") # IO.puts("add #{site_name} at #{path} to #{S.pp(service_map)}") # DEBUG config = load(path) @@ -309,4 +309,29 @@ defmodule SiteConfig do {service, dupe_checked} end) end + + def trim_plugin_name(plug) do + plug + |> Atom.to_string() + |> S.split_prefix("Elixir.Plugins.") + |> then(fn {status, string} -> + if status != false, do: string, else: raise("should have trimmed " <> Atom.to_string(plug)) + end) + end + + def trim_plugin_names(:all), + do: Plugin.ls() |> trim_plugin_names() + + def trim_plugin_names(plist), + do: Enum.map(plist, &trim_plugin_name/1) + + def example_prefix(cfg) do + case cfg.prefix do + [car | _cdr] -> + car + + otherwise -> + otherwise + end + end end diff --git a/lib/stampede.ex b/lib/stampede.ex index be28c42..720cba2 100644 --- a/lib/stampede.ex +++ b/lib/stampede.ex @@ -160,6 +160,25 @@ defmodule Stampede do end end + def split_prefix(text, prefixes) when is_list(prefixes) and is_binary(text) do + prefixes + |> Enum.reduce(nil, fn + _, {s, b} -> + {s, b} + + p, nil -> + {s, b} = split_prefix(text, p) + if s, do: {s, b}, else: nil + end) + |> then(fn + nil -> + {false, text} + + {s, b} -> + {s, b} + end) + end + @spec! if_then(any(), any(), (any() -> any())) :: any() def if_then(value, condition, func) do if condition do diff --git a/lib/stampede/cfg_table.ex b/lib/stampede/cfg_table.ex index 4a3bf6d..5c7f64c 100644 --- a/lib/stampede/cfg_table.ex +++ b/lib/stampede/cfg_table.ex @@ -196,6 +196,39 @@ defmodule Stampede.CfgTable do S.reload_service(cfg) end + # TODO: test + def hot_modify_cfg(service, cfg_id, to_update) when is_map(to_update) do + schema = Service.apply_service_function(service, :site_config_schema, []) + + try_with_table(fn table -> + table + |> Map.update!(__MODULE__, fn service_cfgs -> + Map.update!(service_cfgs, cfg_id, fn cfg -> + for {key, new_value} <- to_update do + [ + "|", + key |> inspect(), + ": ", + Map.get(cfg, key, "unset") |> inspect(), + " -> ", + new_value |> inspect(), + "|\n" + ] + end + |> then(&["| key | old key | new key |\n" | &1]) + |> Logger.info() + + Map.merge(cfg, to_update) + |> Map.to_list() + |> SiteConfig.validate!(schema) + end) + end) + |> table_load() + end) + + :ok + end + @impl GenServer def handle_call({:reload_cfgs, new_dir}, _from, state = %{config_dir: _config_dir}) do :ok = publish_terms(new_dir) diff --git a/lib/txt_block.ex b/lib/txt_block.ex index 7d5bf09..a29ffb4 100644 --- a/lib/txt_block.ex +++ b/lib/txt_block.ex @@ -22,6 +22,7 @@ defmodule TxtBlock do | {:indent, pos_integer() | String.t()} | {:list, :dotted | :numbered} | :italics + | :bold @type! t :: [] | nonempty_list(lazy(t())) | String.t() | lazy(block) @spec! to_binary(t(), module()) :: String.t() @@ -102,6 +103,8 @@ defmodule TxtBlock.Debugging do [ "Testing formats.\n\n", {:italics, "Italicized"}, + " and ", + {:bold, "bolded"}, "\n\n", "Quoted\n", {:quote_block, "Quoted line 1\nQuoted line 2\n"}, diff --git a/lib/txt_block/md.ex b/lib/txt_block/md.ex index b2e9b8e..577348a 100644 --- a/lib/txt_block/md.ex +++ b/lib/txt_block/md.ex @@ -57,4 +57,12 @@ defmodule TxtBlock.Md do "*" ] end + + def format(txt, :bold) do + [ + "**", + txt, + "**" + ] + end end diff --git a/test/stampede_stateless_test.exs b/test/stampede_stateless_test.exs index 338ba61..9cc4eca 100644 --- a/test/stampede_stateless_test.exs +++ b/test/stampede_stateless_test.exs @@ -34,15 +34,14 @@ defmodule StampedeStatelessTest do assert_value S.split_prefix("ping", "!") == {false, "ping"} end - test "SiteConfig.make_regex() test" do - r_binary = "~r/[Ss]\(,\)? " - [prefix: rex] = SiteConfig.make_regex([prefix: r_binary], nil) - assert Regex.source(rex) == String.slice(r_binary, 3, String.length(r_binary) - 3) - assert_value S.split_prefix("S, ping", rex) == {"S, ", "ping"} - assert_value S.split_prefix("S ping", rex) == {"S ", "ping"} - assert_value S.split_prefix("s, ping", rex) == {"s, ", "ping"} - assert_value S.split_prefix("s,, ping", rex) == {false, "s,, ping"} - assert_value S.split_prefix("ping", rex) == {false, "ping"} + test "split_prefix binary list test" do + bl = ["S, ", "S ", "s, ", "s "] + + assert_value S.split_prefix("S, ping", bl) == {"S, ", "ping"} + assert_value S.split_prefix("S ping", bl) == {"S ", "ping"} + assert_value S.split_prefix("s, ping", bl) == {"s, ", "ping"} + assert_value S.split_prefix("s,, ping", bl) == {false, "s,, ping"} + assert_value S.split_prefix("ping", bl) == {false, "ping"} end test "Plugin.is_bot_invoked?" do @@ -58,7 +57,7 @@ defmodule StampedeStatelessTest do [ [%{}, %{at_bot?: true}], [%{}, %{dm?: true}], - [%{}, %{prefix: "something"}] + [%{}, %{prefix: {"!", "ping"}}] ] |> Enum.map(fn [cfg_overrides, msg_overrides] -> @@ -341,7 +340,7 @@ defmodule StampedeStatelessTest do assert_value processed == """ Testing formats. - *Italicized* + *Italicized* and **bolded** Quoted > Quoted line 1 diff --git a/test/stampede_test.exs b/test/stampede_test.exs index 26917c6..992765d 100644 --- a/test/stampede_test.exs +++ b/test/stampede_test.exs @@ -3,6 +3,7 @@ defmodule StampedeTest do import ExUnit.CaptureLog alias Stampede, as: S alias Service.Dummy, as: D + import AssertValue doctest Stampede @confused_response S.confused_response() |> TxtBlock.to_binary(Service.Dummy) @@ -13,18 +14,19 @@ defmodule StampedeTest do error_channel_id: error prefix: "!" plugs: - - Test - Sentience + - Test - Why + - Help """ @dummy_cfg_verified %{ service: Service.Dummy, server_id: :testing, error_channel_id: :error, prefix: "!", - plugs: MapSet.new([Plugins.Test, Plugins.Sentience, Plugins.Why]), + plugs: MapSet.new([Plugins.Test, Plugins.Sentience, Plugins.Why, Plugins.Help]), dm_handler: false, - filename: :"test SiteConfig load_all", + filename: "test SiteConfig load_all", vip_ids: MapSet.new([:server]), bot_is_loud: false } @@ -45,8 +47,13 @@ defmodule StampedeTest do setup context do id = context.test - if Map.get(context, :dummy, false) do - :ok = D.new_server(id, MapSet.new([Plugins.Test, Plugins.Sentience, Plugins.Why])) + if context[:dummy] do + @dummy_cfg_verified + |> Map.to_list() + |> Keyword.put(:server_id, id) + |> Keyword.put(:filename, id |> Atom.to_string()) + |> Keyword.merge(context[:cfg_overrides] || []) + |> D.new_server() end %{id: id} @@ -176,6 +183,17 @@ defmodule StampedeTest do r3 = D.ask_bot(s.id, :t1, :u1, "ping", ref: non_bot_id) assert r3 == nil, "bot responded to tag of someone else's message" end + + @tag cfg_overrides: [prefix: ["a ", "b "]] + test "multi-prefixes", s do + r = D.ask_bot(s.id, :t1, :u1, "a ping") + + assert r.text == "pong!" + + r = D.ask_bot(s.id, :t1, :u1, "b ping") + + assert r.text == "pong!" + end end describe "dummy server channels" do @@ -309,4 +327,37 @@ defmodule StampedeTest do assert decisions == [{{:delete, 2468}, {:unset, :chan_c}}] end end + + describe "Help plugin" do + @describetag :dummy + test "parsing" do + assert :list_plugins == Plugins.Help.summon_type("help") + assert :list_plugins == Plugins.Help.summon_type("list plugin") + assert :list_plugins == Plugins.Help.summon_type("list plugins") + assert {:specific, "Why"} == Plugins.Help.summon_type("help Why") + end + + test "responses", s do + assert_value D.ask_bot(s.id, :t1, :u1, "!list plugins") |> Map.fetch!(:text) == """ + Here are the available plugins! Learn about any of them with `help [plugin]` + + - **Help**: Describes how the bot can be used. You're using it right now! + - **Sentience**: This plugin only responds when Stampede was specifically requested, but all other plugins failed. + - **Test**: A set of functions for testing Stampede functionality. + - **Why**: Explains the bot's reasoning for posting a particular message, if it remembers it. Summoned with \"why did you say that?\" for a short summary. Remember to identify the message you want; on Discord, this is the \"reply\" function. If you want a full traceback, ask with \"specifically\". + + """ + + assert_value D.ask_bot(s.id, :t1, :u1, "!help why") |> Map.fetch!(:text) == """ + Explains the bot's reasoning for posting a particular message, if it remembers it. Summoned with \"why did you say that?\" for a short summary. Remember to identify the message you want; on Discord, this is the \"reply\" function. If you want a full traceback, ask with \"specifically\". + Full regex: `[Ww]h(?:(?:y did)|(?:at made)) you say th(?:(?:at)|(?:is))(?P,? specifically)?` + + Usage: + - Magic phrase: `(why did/what made) you say (that/this)[, specifically][?]` + - `!why did you say that? (tagging bot message)` **<>** `(reason for posting this message)` + - `!what made you say that, specifically? (tagging bot message)` **<>** `(full traceback of the creation of this message)` + - `!why did you say this (tagging unknown message)` **<>** `Couldn't find an interaction for message \"some_msg_id\".` + """ + end + end end