summaryrefslogtreecommitdiff
path: root/lib/nola_plugins/txt_plugin.ex
diff options
context:
space:
mode:
authorJordan Bracco <href@random.sh>2022-12-20 00:21:54 +0000
committerJordan Bracco <href@random.sh>2022-12-20 19:29:41 +0100
commit2d83df8b32bff7f0028923bb5b64dc0b55f20d03 (patch)
tree1207e67b5b15f540963db05e7be89f3ca950e724 /lib/nola_plugins/txt_plugin.ex
parentNola rename, the end. pt 6. Refs T77. (diff)
Nola rename: The Big Move, Refs T77
Diffstat (limited to 'lib/nola_plugins/txt_plugin.ex')
-rw-r--r--lib/nola_plugins/txt_plugin.ex556
1 files changed, 556 insertions, 0 deletions
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>`**: recherche une ligne dans tous les fichiers.
+
+ * **~txt**: essaie de générer une phrase (markov).
+ * **~txt `<début>`**: essaie de générer une phrase commencant par `<debut>`.
+
+ * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`.
+ * **!`FICHIER` `<chiffre>`**: lis la ligne `<chiffre>` du fichier `FICHIER`.
+ * **!`FICHIER` `<recherche>`**: recherche une ligne contenant `<recherche>` dans `FICHIER`.
+
+ * **+txt `<file`>**: crée le fichier `<file>`.
+ * **+`FICHIER` `<texte>`**: ajoute une ligne `<texte>` dans le fichier `FICHIER`.
+ * **-`FICHIER` `<chiffre>`**: supprime la ligne `<chiffre>` du fichier `FICHIER`.
+
+ * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule.
+ * **+txtlock `<fichier>`, -txtlock `<fichier>`**. 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