summaryrefslogblamecommitdiff
path: root/lib/nola_plugins/txt_plugin.ex
blob: cab912affbf25d3e946e2dd6f1849165764269d2 (plain) (tree)
1
2
3
4
5
6
7
8
9
                               
                     
                
 
                
                               
 
                                                 
                                               
                                          
 





                                                                                 

                                                                              
                                                                                             




                                                                                   
                                                                              
                                                                                                                 

                                              

     
                                                               

                             
                     
                                                          

     
                                                                                 
 













                                               
                 
                                                                                            
                                                           
                                                                                                                               
                                               
                                                                              
                                                                                                      





                                                            




                                                                                                                         
                                                                               
                                                 





                                               
                                                                                                                         
                                                                               
                                                  





                                                


                
 
                                                                                                           

                                             
                                                                            

                                          
                                                  



                     
                                                                                                            

                                             
                                                                             


                                                
                                                     



                     


             
 
                                                                                     
                                                     
                                          




                                                      
                                                                            
        
                        




                                                                                                                                  



                                                                
                      


                     













                                                                                                
                                                          


                     


                                                                                                  



                                                                                           
                                                                 






                                                   
          





                                              
                       
        
 

                             
                                           



                     







                                                         




















































                                                        







                                                                                                 





                                                        






                                                                                                       



                     




                                                                                                       

                                             
                                              

                                 
                                                                                              

                                                       
                            


       



               



                                                                                                   
                                                                                                                  
          
                                                                       





                              

                                                                                                    
                                                                                      
              
                                                








                                                                                                       
          

                                                 
      
                                                                          

                                                       



                                                                                 
                         


       




                                                                                                     
          
                                              





                                                                     
                                                                                   


                                                       
          



                         
                                                                                           
                                                             

                     
 



                                




                                                      







                                  

                                     
                                                     

                                      
                                            
                                













                                


                                






                                            
                                                                        

     
                                                







                                           
                                                 




                                          
                                               

     
                                                               






                                                                 








                                                                     


                           

                                                     





                                                            

 
                                             









                                                                                                                   
            
                          
           
                            










                                                              
                                          
                                          
                       
                       



                   
                                       
                                                 







                                    
                         


        
                    
                                                         

     
                                                                                              
                               




                                                          

                              


                                                                         
                                                                      



        

                                                                                                                    
                                                                          











                                                                         
     

   
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