From 2d83df8b32bff7f0028923bb5b64dc0b55f20d03 Mon Sep 17 00:00:00 2001 From: Jordan Bracco Date: Tue, 20 Dec 2022 00:21:54 +0000 Subject: Nola rename: The Big Move, Refs T77 --- lib/nola_plugins/txt_plugin.ex | 556 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 lib/nola_plugins/txt_plugin.ex (limited to 'lib/nola_plugins/txt_plugin.ex') diff --git a/lib/nola_plugins/txt_plugin.ex b/lib/nola_plugins/txt_plugin.ex new file mode 100644 index 0000000..cab912a --- /dev/null +++ b/lib/nola_plugins/txt_plugin.ex @@ -0,0 +1,556 @@ +defmodule Nola.IRC.TxtPlugin do + alias IRC.UserTrack + require Logger + + @moduledoc """ + # [txt]({{context_path}}/txt) + + * **.txt**: liste des fichiers et statistiques. + Les fichiers avec une `*` sont vérrouillés. + [Voir sur le web]({{context_path}}/txt). + + * **!txt**: lis aléatoirement une ligne dans tous les fichiers. + * **!txt ``**: recherche une ligne dans tous les fichiers. + + * **~txt**: essaie de générer une phrase (markov). + * **~txt ``**: essaie de générer une phrase commencant par ``. + + * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. + * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. + * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. + + * **+txt `**: crée le fichier ``. + * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. + * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. + + * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. + * **+txtlock ``, -txtlock ``**. op seulement. active/désactive le verrouillage d'un fichier. + + Insérez `\\\\` pour faire un saut de ligne. + """ + + def short_irc_doc, do: "!txt https://sys.115ans.net/irc/txt " + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil + + def random(file) do + GenServer.call(__MODULE__, {:random, file}) + end + + def reply_random(message, file) do + if line = random(file) do + line + |> format_line(nil, message) + |> message.replyfun.() + + line + end + end + + def init([]) do + dets_locks_filename = (Nola.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist + {:ok, locks} = :dets.open_file(dets_locks_filename, []) + markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.Markov.Native) + {:ok, markov} = markov_handler.start_link() + {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, %__MODULE__{locks: locks, markov_handler: markov_handler, markov: markov, triggers: load()}} + end + + def handle_info({:received, "!reload", _, chan}, state) do + {:noreply, %__MODULE__{state | triggers: load()}} + end + + # + # ADMIN: RW/RO + # + + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do + if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do + msg.replyfun.("txt: écriture réactivée") + {:noreply, %__MODULE__{state | rw: true}} + else + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do + if channel && UserTrack.operator?(msg.network, channel, msg.sender.nick) do + msg.replyfun.("txt: écriture désactivée") + {:noreply, %__MODULE__{state | rw: false}} + else + {:noreply, state} + end + end + + # + # ADMIN: LOCKS + # + + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick) + do + :dets.insert(state.locks, {trigger}) + msg.replyfun.("txt: #{trigger} verrouillé") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- UserTrack.operator?(msg.network, msg.channel, msg.sender.nick), + true <- :dets.member(state.locks, trigger) + do + :dets.delete(state.locks, trigger) + msg.replyfun.("txt: #{trigger} déverrouillé") + end + {:noreply, state} + end + + # + # FILE LIST + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do + map = Enum.map(state.triggers, fn({key, data}) -> + ignore? = String.contains?(key, ".") + locked? = case :dets.lookup(state.locks, key) do + [{trigger}] -> "*" + _ -> "" + end + + unless ignore?, do: "#{key}: #{to_string(Enum.count(data))}#{locked?}" + end) + |> Enum.filter(& &1) + total = Enum.reduce(state.triggers, 0, fn({_, data}, acc) -> + acc + Enum.count(data) + end) + detail = Enum.join(map, ", ") + total = ". total: #{Enum.count(state.triggers)} fichiers, #{to_string(total)} lignes. Détail: https://sys.115ans.net/irc/txt" + + ro = if !state.rw, do: " (lecture seule activée)", else: "" + + (detail<>total<>ro) + |> msg.replyfun.() + {:noreply, state} + end + + # + # GLOBAL: RANDOM + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do + result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) + end) + |> Enum.shuffle() + + if !Enum.empty?(result) do + {source, line} = Enum.random(result) + msg.replyfun.(format_line(line, "#{source}: ", msg)) + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do + grep = Enum.join(args, " ") + |> String.downcase + |> :unicode.characters_to_nfd_binary() + + result = with_stateful_results(msg, {:bang,"txt",msg.network,msg.channel,grep}, fn() -> + Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + if !String.contains?(trigger, ".") do + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) + else + acc + end + end) + |> Enum.filter(fn({_, line}) -> + line + |> String.downcase() + |> :unicode.characters_to_nfd_binary() + |> String.contains?(grep) + end) + |> Enum.shuffle() + end) + + if result do + {source, line} = result + msg.replyfun.(["#{source}: " | line]) + end + {:noreply, state} + end + + def with_stateful_results(msg, key, initfun) do + me = self() + scope = {msg.network, msg.channel || msg.sender.nick} + key = {__MODULE__, me, scope, key} + with_stateful_results(key, initfun) + end + + def with_stateful_results(key, initfun) do + pid = case :global.whereis_name(key) do + :undefined -> + start_stateful_results(key, initfun.()) + pid -> pid + end + if pid, do: wait_stateful_results(key, initfun, pid) + end + + def start_stateful_results(key, []) do + nil + end + + def start_stateful_results(key, list) do + me = self() + {pid, _} = spawn_monitor(fn() -> + Process.monitor(me) + stateful_results(me, list) + end) + :yes = :global.register_name(key, pid) + pid + end + + def wait_stateful_results(key, initfun, pid) do + send(pid, :get) + receive do + {:stateful_results, line} -> + line + {:DOWN, _ref, :process, ^pid, reason} -> + with_stateful_results(key, initfun) + after + 5000 -> + nil + end + end + + defp stateful_results(owner, []) do + send(owner, :empty) + :ok + end + + @stateful_results_expire :timer.minutes(30) + defp stateful_results(owner, [line | rest] = acc) do + receive do + :get -> + send(owner, {:stateful_results, line}) + stateful_results(owner, rest) + {:DOWN, _ref, :process, ^owner, _} -> + :ok + after + @stateful_results_expire -> :ok + end + end + + # + # GLOBAL: MARKOV + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: []}}}, state) do + case state.markov_handler.sentence(state.markov) do + {:ok, line} -> + msg.replyfun.(line) + error -> + Logger.error "Txt Markov error: "<>inspect error + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :tilde, args: complete}}}, state) do + complete = Enum.join(complete, " ") + case state.markov_handler.complete_sentence(complete, state.markov) do + {:ok, line} -> + msg.replyfun.(line) + error -> + Logger.error "Txt Markov error: "<>inspect error + end + {:noreply, state} + end + + # + # TXT CREATE + # + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do + with \ + {trigger, _} <- clean_trigger(trigger), + true <- can_write?(state, msg, trigger), + :ok <- create_file(trigger) + do + msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") + {:noreply, %__MODULE__{state | triggers: load()}} + else + _ -> {:noreply, state} + end + end + + # + # TXT: RANDOM + # + + def handle_info({:irc, :trigger, trigger, m = %{trigger: %{type: :query, args: opts}}}, state) do + {trigger, _} = clean_trigger(trigger) + if Map.get(state.triggers, trigger) do + url = if m.channel do + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, m.network, NolaWeb.format_chan(m.channel), trigger) + else + NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :txt, trigger) + end + m.replyfun.("-> #{url}") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do + {trigger, _} = clean_trigger(trigger) + line = get_random(msg, state.triggers, trigger, String.trim(Enum.join(opts, " "))) + if line do + msg.replyfun.(format_line(line, nil, msg)) + end + {:noreply, state} + end + + # + # TXT: ADD + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do + with \ + true <- can_write?(state, msg, trigger), + {:ok, idx} <- add(state.triggers, msg.text) + do + msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") + {:noreply, %__MODULE__{state | triggers: load()}} + else + {:error, {:jaro, string, idx}} -> + msg.replyfun.("#{msg.sender.nick}: doublon #{trigger}##{idx}: #{string}") + error -> + Logger.debug("txt add failed: #{inspect error}") + {:noreply, state} + end + end + + # + # TXT: DELETE + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do + with \ + true <- can_write?(state, msg, trigger), + data <- Map.get(state.triggers, trigger), + {id, ""} <- Integer.parse(id), + {text, _id} <- Enum.find(data, fn({_, idx}) -> id-1 == idx end) + do + data = data |> Enum.into(Map.new) + data = Map.delete(data, text) + msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") + dump(trigger, data) + {:noreply, %__MODULE__{state | triggers: load()}} + else + _ -> + {:noreply, state} + end + end + + def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do + state.markov_handler.reload(state.triggers, state.markov) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def handle_call({:random, file}, _from, state) do + random = get_random(nil, state.triggers, file, []) + {:reply, random, state} + end + + def terminate(_reason, state) do + if state.locks do + :dets.sync(state.locks) + :dets.close(state.locks) + end + :ok + end + + # Load/Reloads text files from disk + defp load() do + triggers = Path.wildcard(directory() <> "/*.txt") + |> Enum.reduce(%{}, fn(path, m) -> + file = Path.basename(path) + key = String.replace(file, ".txt", "") + data = directory() <> file + |> File.read! + |> String.split("\n") + |> Enum.reject(fn(line) -> + cond do + line == "" -> true + !line -> true + true -> false + end + end) + |> Enum.with_index + Map.put(m, key, data) + end) + |> Enum.sort + |> Enum.into(Map.new) + + send(self(), :reload_markov) + triggers + end + + defp dump(trigger, data) do + data = data + |> Enum.sort_by(fn({_, idx}) -> idx end) + |> Enum.map(fn({text, _}) -> text end) + |> Enum.join("\n") + File.write!(directory() <> "/" <> trigger <> ".txt", data<>"\n", []) + end + + defp get_random(msg, triggers, trigger, []) do + if data = Map.get(triggers, trigger) do + {data, _idx} = Enum.random(data) + data + else + nil + end + end + + defp get_random(msg, triggers, trigger, opt) do + arg = case Integer.parse(opt) do + {pos, ""} -> {:index, pos} + {_pos, _some_string} -> {:grep, opt} + _error -> {:grep, opt} + end + get_with_param(msg, triggers, trigger, arg) + end + + defp get_with_param(msg, triggers, trigger, {:index, pos}) do + data = Map.get(triggers, trigger, %{}) + case Enum.find(data, fn({_, index}) -> index+1 == pos end) do + {text, _} -> text + _ -> nil + end + end + + defp get_with_param(msg, triggers, trigger, {:grep, query}) do + out = with_stateful_results(msg, {:grep, trigger, query}, fn() -> + data = Map.get(triggers, trigger, %{}) + regex = Regex.compile!("#{query}", "i") + Enum.filter(data, fn({txt, _}) -> Regex.match?(regex, txt) end) + |> Enum.map(fn({txt, _}) -> txt end) + |> Enum.shuffle() + end) + if out, do: out + end + + defp create_file(name) do + File.touch!(directory() <> "/" <> name <> ".txt") + :ok + end + + defp add(triggers, trigger_and_content) do + case String.split(trigger_and_content, " ", parts: 2) do + [trigger, content] -> + {trigger, _} = clean_trigger(trigger) + + + if Map.has_key?(triggers, trigger) do + jaro = Enum.find(triggers[trigger], fn({string, idx}) -> String.jaro_distance(content, string) > 0.9 end) + + if jaro do + {string, idx} = jaro + {:error, {:jaro, string, idx}} + else + File.write!(directory() <> "/" <> trigger <> ".txt", content<>"\n", [:append]) + idx = Enum.count(triggers[trigger])+1 + {:ok, idx} + end + else + {:error, :notxt} + end + _ -> {:error, :badarg} + end + end + + # fixme: this is definitely the ugliest thing i've ever done + defp clean_trigger(trigger) do + [trigger | opts] = trigger + |> String.strip + |> String.split(" ", parts: 2) + + trigger = trigger + |> String.downcase + |> :unicode.characters_to_nfd_binary() + |> String.replace(~r/[^a-z0-9._]/, "") + |> String.trim(".") + |> String.trim("_") + + {trigger, opts} + end + + def format_line(line, prefix, msg) do + prefix = unless(prefix, do: "", else: prefix) + prefix <> line + |> String.split("\\\\") + |> Enum.map(fn(line) -> + String.split(line, "\\\\\\\\") + end) + |> List.flatten() + |> Enum.map(fn(line) -> + String.trim(line) + |> Tmpl.render(msg) + end) + end + + def directory() do + Application.get_env(:nola, :data_path) <> "/irc.txt/" + end + + defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do + admin? = IRC.admin?(sender) + locked? = case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? + + can? = unlocked? || admin? + + if !can? do + reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" + msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") + end + can? + end + + defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do + admin? = IRC.admin?(sender) + operator? = IRC.UserTrack.operator?(msg.network, channel, sender.nick) + locked? = case :dets.lookup(locks, trigger) do + [{trigger}] -> true + _ -> false + end + unlocked? = if rw? == false, do: false, else: !locked? + can? = admin? || operator? || unlocked? + + if !can? do + reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" + msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") + end + can? + end + +end -- cgit v1.2.3