diff options
Diffstat (limited to '')
35 files changed, 1916 insertions, 354 deletions
@@ -31,4 +31,29 @@ defmodule IRC do defp admin_part_match?(a, a), do: true defp admin_part_match?(_, _), do: false + @max_chars 440 + + def splitlong(string, max_chars \\ 440) + + def splitlong(string, max_chars) when is_list(string) do + Enum.map(string, fn(s) -> splitlong(s, max_chars) end) + |> List.flatten() + end + + def splitlong(string, max_chars) do + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(&Enum.join/1) + end + + def splitlong_with_prefix(string, prefix, max_chars \\ 440) do + prefix = "#{prefix} " + max_chars = max_chars - (length(String.codepoints(prefix))) + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + end + end diff --git a/lib/irc/pubsub_handler.ex b/lib/irc/pubsub_handler.ex index 3e78d7b..450dc2f 100644 --- a/lib/irc/pubsub_handler.ex +++ b/lib/irc/pubsub_handler.ex @@ -6,7 +6,7 @@ defmodule IRC.PubSubHandler do ## PubSub topics - * `message` -- all messages (including triggers) + * `message` -- all messages (without triggers) * `message:private` -- all messages without a channel * `message:#CHANNEL` -- all messages within `#CHANNEL` * `triggers` -- all triggers @@ -34,6 +34,7 @@ defmodule IRC.PubSubHandler do "-" => :minus, "?" => :query, "." => :dot, + "~" => :tilde } def handle_info({:received, text, sender, chan}, client) do @@ -62,7 +63,7 @@ defmodule IRC.PubSubHandler do end defp publish(m = %IRC.Message{trigger: t = %IRC.Trigger{trigger: trigger}}, keys) do - dispatch(["message", "triggers", "trigger:"<>trigger]++keys, {:irc, :trigger, trigger, m}) + dispatch(["triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) end defp dispatch(key, content) when is_binary(key), do: dispatch([key], content) @@ -96,8 +97,10 @@ defmodule IRC.PubSubHandler do # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} - defp irc_reply(client, {target, _}, text) when is_binary(text) do - ExIRC.Client.msg(client, :privmsg, target, text) + defp irc_reply(client, {target, _}, text) when is_binary(text) or is_list(text) do + for line <- IRC.splitlong(text) do + ExIRC.Client.msg(client, :privmsg, target, line) + end end defp irc_reply(client, {target, %{nick: nick}}, {:kick, reason}) do diff --git a/lib/lsg/application.ex b/lib/lsg/application.ex index cc4c120..8ee8aa2 100644 --- a/lib/lsg/application.ex +++ b/lib/lsg/application.ex @@ -14,7 +14,7 @@ defmodule LSG.Application do # worker(LSG.Worker, [arg1, arg2, arg3]), worker(Registry, [[keys: :duplicate, name: LSG.BroadcastRegistry]], id: :registry_broadcast), worker(LSG.IcecastAgent, []), - worker(LSG.Icecast, []), + #worker(LSG.Icecast, []), ] ++ LSG.IRC.application_childs # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/lsg_irc/.#youtube_handler.ex b/lib/lsg_irc/.#youtube_handler.ex new file mode 120000 index 0000000..0557ec1 --- /dev/null +++ b/lib/lsg_irc/.#youtube_handler.ex @@ -0,0 +1 @@ +href@echoes.1780
\ No newline at end of file diff --git a/lib/lsg_irc/alcolog_plugin.ex b/lib/lsg_irc/alcolog_plugin.ex new file mode 100644 index 0000000..b5392a3 --- /dev/null +++ b/lib/lsg_irc/alcolog_plugin.ex @@ -0,0 +1,200 @@ +defmodule LSG.IRC.AlcologPlugin do + require Logger + + @moduledoc """ + # alcoolisme _(v2)_ + + * **`!santai <montant> <degrés d'alcool> [annotation]`** enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + + * **!`<trigger>` `[coeff]` `[annotation]`** enregistre de l'alcoolisme. + * **!alcoolisme `[pseudo]`** affiche les points d'alcoolisme. + + Triggers/Coeffs: + + * bière: (coeffs: 25, 50/+, 75/++, 100/+++, ++++) + * pinard: (coeffs: +, ++, +++, ++++) + * shot/fort/whisky/rhum/..: (coeffs: +, ++, +++, ++++) + * eau (-1) + + Annotation: champ libre! + + """ + + @triggers %{ + "apero" => + %{ + triggers: ["apero", "apéro", "apairo", "santai"], + default_coeff: "25", + coeffs: %{ + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + } + }, + "bière" => + %{ + triggers: ["beer", "bière", "biere", "biaire"], + default_coeff: "25", + coeffs: %{ + "25" => 1, + "50" => 2, + "75" => 3, + "100" => 4, + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + } + }, + "pinard" => %{ + triggers: ["pinard", "vin", "rouge", "blanc", "rosé", "rose"], + annotations: ["rouge", "blanc", "rosé", "rose"], + default_coeff: "", + coeffs: %{ + "" => 1, + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + "vase" => 6, + } + }, + "fort" => %{ + triggers: ["shot", "royaume", "whisky", "rhum", "armagnac", "dijo"], + default_coeff: "", + coeffs: %{ + "" => 3, + "+" => 5, + "++" => 7, + "+++" => 9, + "++++" => 11 + } + }, + "eau" => %{ + triggers: ["eau"], + default_coeff: "1", + coeffs: %{ + "1" => -2, + "+" => -3, + "++" => -4, + "+++" => -6, + "++++" => -8 + } + } + } + + @santai ["SANTÉ", "SANTÉ", "SANTAIIII", "SANTAIIIIIIIIII", "SANTAI", "A LA TIENNE"] + + def irc_doc, do: @moduledoc + + def start_link(), do: GenServer.start_link(__MODULE__, []) + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolisme", []) + for {_, config} <- @triggers do + for trigger <- config.triggers do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}", []) + for coeff when byte_size(coeff) > 0 <- Map.keys(config.coeffs) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}#{coeff}", []) + end + end + end + dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + {:ok, dets} + end + + for {name, config} <- @triggers do + coeffs = Map.get(config, "coeffs") + default_coeff_value = Map.get(config.coeffs, config.default_coeff) + + + IO.puts "at triggers #{inspect config}" + # Handle each trigger + for trigger <- config.triggers do + + # … with a known coeff … + for {coef, value} when byte_size(coef) > 0 <- config.coeffs do + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(value), m, args, dets) + {:noreply, dets} + end + def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(value), m, args, dets) + {:noreply, dets} + end + end + + # … or without + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(default_coeff_value), m, args, dets) + {:noreply, dets} + end + + end + + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + nick = case args do + [nick] -> nick + [] -> m.sender.nick + end + case get_statistics_for_nick(dets, nick) do + {count, {_, last_at, last_points, last_type, last_descr}} -> + m.replyfun.("#{nick} a #{count} points d'alcoolisme. Dernier verre: #{present_type(last_type, + last_descr)} [#{last_points}] #{format_relative_timestamp(last_at)}") + _ -> + m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") + end + {:noreply, dets} + end + + defp handle_bang(name, points, message, args, dets) do + description = case args do + [] -> nil + [something] -> something + something when is_list(something) -> Enum.join(something, " ") + _ -> nil + end + now = DateTime.to_unix(DateTime.utc_now(), :milliseconds) + :ok = :dets.insert(dets, {String.downcase(message.sender.nick), now, points, name, description}) + {count, {_, last_at, last_points, last_type, last_descr}} = get_statistics_for_nick(dets, message.sender.nick) + sante = @santai |> Enum.shuffle() |> Enum.random() + message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! (total #{count} points)") + end + + defp get_statistics_for_nick(dets, nick) do + qvc = :dets.lookup(dets, String.downcase(nick)) + IO.puts inspect(qvc) + count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _type, _descr}, acc) -> acc + points end) + last = List.last(qvc) || nil + {count, last} + end + + def present_type(type, descr) when descr in [nil, ""], do: "#{type}" + def present_type(type, description), do: "#{type} (#{description})" + + def format_points(int) when int > 0 do + "+#{Integer.to_string(int)}" + end + def format_points(int) when int < 0 do + Integer.to_string(int) + end + def format_points(0), do: "0" + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix!(:milliseconds) + |> Timezone.convert("Europe/Paris") + + {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") + + relative <> detail + end +end + diff --git a/lib/lsg_irc/alcoolisme_plugin.ex b/lib/lsg_irc/alcoolisme_plugin.ex new file mode 100644 index 0000000..03dc75b --- /dev/null +++ b/lib/lsg_irc/alcoolisme_plugin.ex @@ -0,0 +1,198 @@ +defmodule LSG.IRC.AlcoolismePlugin do + require Logger + + @moduledoc """ + # Alcoolisme + + * **!`<trigger>` `[coeff]` `[annotation]`** enregistre de l'alcoolisme. + * **!alcoolisme `[pseudo]`** affiche les points d'alcoolisme. + + Triggers/Coeffs: + + * bière: (coeffs: 25, 50/+, 75/++, 100/+++, ++++) + * pinard: (coeffs: +, ++, +++, ++++) + * shot/fort/whisky/rhum/..: (coeffs: +, ++, +++, ++++) + * eau (-1) + + Annotation: champ libre! + + """ + + @triggers %{ + "apero" => + %{ + triggers: ["apero", "apéro", "apairo", "santai"], + default_coeff: "25", + coeffs: %{ + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + } + }, + "bière" => + %{ + triggers: ["beer", "bière", "biere", "biaire"], + default_coeff: "25", + coeffs: %{ + "25" => 1, + "50" => 2, + "75" => 3, + "100" => 4, + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + } + }, + "pinard" => %{ + triggers: ["pinard", "vin", "rouge", "blanc", "rosé", "rose"], + annotations: ["rouge", "blanc", "rosé", "rose"], + default_coeff: "", + coeffs: %{ + "" => 1, + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5, + "vase" => 6, + } + }, + "fort" => %{ + triggers: ["shot", "royaume", "whisky", "rhum", "armagnac", "dijo"], + default_coeff: "", + coeffs: %{ + "" => 3, + "+" => 5, + "++" => 7, + "+++" => 9, + "++++" => 11 + } + }, + "eau" => %{ + triggers: ["eau"], + default_coeff: "1", + coeffs: %{ + "1" => -2, + "+" => -3, + "++" => -4, + "+++" => -6, + "++++" => -8 + } + } + } + + @santai ["SANTÉ", "SANTÉ", "SANTAIIII", "SANTAIIIIIIIIII", "SANTAI", "A LA TIENNE"] + + def irc_doc, do: @moduledoc + + def start_link(), do: GenServer.start_link(__MODULE__, []) + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolisme", []) + for {_, config} <- @triggers do + for trigger <- config.triggers do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}", []) + for coeff when byte_size(coeff) > 0 <- Map.keys(config.coeffs) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}#{coeff}", []) + end + end + end + dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + {:ok, dets} + end + + for {name, config} <- @triggers do + coeffs = Map.get(config, "coeffs") + default_coeff_value = Map.get(config.coeffs, config.default_coeff) + + + IO.puts "at triggers #{inspect config}" + # Handle each trigger + for trigger <- config.triggers do + + # … with a known coeff … + for {coef, value} when byte_size(coef) > 0 <- config.coeffs do + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(value), m, args, dets) + {:noreply, dets} + end + def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(value), m, args, dets) + {:noreply, dets} + end + end + + # … or without + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + handle_bang(unquote(name), unquote(default_coeff_value), m, args, dets) + {:noreply, dets} + end + + end + + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do + nick = case args do + [nick] -> nick + [] -> m.sender.nick + end + case get_statistics_for_nick(dets, nick) do + {count, {_, last_at, last_points, last_type, last_descr}} -> + m.replyfun.("#{nick} a #{count} points d'alcoolisme. Dernier verre: #{present_type(last_type, + last_descr)} [#{last_points}] #{format_relative_timestamp(last_at)}") + _ -> + m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") + end + {:noreply, dets} + end + + defp handle_bang(name, points, message, args, dets) do + description = case args do + [] -> nil + [something] -> something + something when is_list(something) -> Enum.join(something, " ") + _ -> nil + end + now = DateTime.to_unix(DateTime.utc_now(), :milliseconds) + :ok = :dets.insert(dets, {String.downcase(message.sender.nick), now, points, name, description}) + {count, {_, last_at, last_points, last_type, last_descr}} = get_statistics_for_nick(dets, message.sender.nick) + sante = @santai |> Enum.shuffle() |> Enum.random() + message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! (total #{count} points)") + end + + defp get_statistics_for_nick(dets, nick) do + qvc = :dets.lookup(dets, String.downcase(nick)) + IO.puts inspect(qvc) + count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _type, _descr}, acc) -> acc + points end) + last = List.last(qvc) || nil + {count, last} + end + + def present_type(type, descr) when descr in [nil, ""], do: "#{type}" + def present_type(type, description), do: "#{type} (#{description})" + + def format_points(int) when int > 0 do + "+#{Integer.to_string(int)}" + end + def format_points(int) when int < 0 do + Integer.to_string(int) + end + def format_points(0), do: "0" + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix!(:milliseconds) + |> Timezone.convert("Europe/Paris") + + {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") + {:ok, detail} = Formatters.Default.lformat(date, " ({h24}:{m})", "fr") + + relative <> detail + end +end + diff --git a/lib/lsg_irc/base_handler.ex b/lib/lsg_irc/base_handler.ex deleted file mode 100644 index 35b2ade..0000000 --- a/lib/lsg_irc/base_handler.ex +++ /dev/null @@ -1,35 +0,0 @@ -defmodule LSG.IRC.BaseHandler do - - def irc_doc, do: nil - - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler client, self - {:ok, client} - end - - def handle_info({:received, "!help", %ExIRC.SenderInfo{nick: nick}, chan}, client) do - url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) - ExIRC.Client.msg(client, :privmsg, chan, "#{nick}: #{url}") - {:noreply, client} - end - - def handle_info({:received, "version", %ExIRC.SenderInfo{nick: nick}}, client) do - {:ok, vsn} = :application.get_key(:lsg, :vsn) - ver = List.to_string(vsn) - url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) - version = "v#{ver} ; #{url} ; source: https://git.yt/115ans/sys" - ExIRC.Client.msg(client, :privmsg, nick, version) - {:noreply, client} - end - - def handle_info(msg, client) do - IO.inspect(msg) - {:noreply, client} - end - -end - diff --git a/lib/lsg_irc/base_plugin.ex b/lib/lsg_irc/base_plugin.ex new file mode 100644 index 0000000..c0cfc59 --- /dev/null +++ b/lib/lsg_irc/base_plugin.ex @@ -0,0 +1,35 @@ +defmodule LSG.IRC.BasePlugin do + + def irc_doc, do: nil + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", []) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "help", message = %{trigger: %{type: :bang}}}, _) do + url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) + message.replyfun.(url) + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do + {:ok, vsn} = :application.get_key(:lsg, :vsn) + ver = List.to_string(vsn) + url = LSGWeb.Router.Helpers.irc_url(LSGWeb.Endpoint, :index) + version = "v#{ver} ; #{url} ; source: https://git.yt/115ans/sys" + message.replyfun.(version) + {:noreply, nil} + end + + def handle_info(msg, _) do + {:noreply, nil} + end + +end + diff --git a/lib/lsg_irc/coronavirus_plugin.ex b/lib/lsg_irc/coronavirus_plugin.ex new file mode 100644 index 0000000..9a017f3 --- /dev/null +++ b/lib/lsg_irc/coronavirus_plugin.ex @@ -0,0 +1,131 @@ +defmodule LSG.IRC.CoronavirusPlugin do + require Logger + @moduledoc """ + # Corona Virus + + Données de [Johns Hopkins University](https://github.com/CSSEGISandData/COVID-19) et mises à jour a peu près tous les jours. + + * `!coronavirus [France | Country]`: :-) + * `!coronavirus`: top 10 confirmés et non guéris + * `!coronavirus confirmés`: top 10 confirmés + * `!coronavirus morts`: top 10 morts + * `!coronavirus soignés`: top 10 soignés + """ + def irc_doc, do: @moduledoc + + def start_link(), do: GenServer.start_link(__MODULE__, []) + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", []) + {data, next} = fetch_data(%{}) + :timer.send_after(next, :update) + {:ok, %{data: data}} + end + + def handle_info(:update, state) do + {data, next} = fetch_data(state.data) + :timer.send_after(next, :update) + {:noreply, %{data: data}} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [[], ["morts"], ["confirmés"], ["soignés"], ["malades"]] do + {field, name} = case args do + ["confirmés"] -> {:confirmed, "confirmés"} + ["morts"] -> {:deaths, "morts"} + ["soignés"] -> {:recovered, "soignés"} + _ -> {:current, "malades"} + end + sorted = state.data + |> Enum.filter(fn({_, %{region: region}}) -> region == true end) + |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0)} end) + |> Enum.sort_by(fn({_,count}) -> count end, &>=/2) + |> Enum.take(10) + |> Enum.with_index() + |> Enum.map(fn({{location, count}, index}) -> + "##{index+1}: #{location} #{count}" + end) + |> Enum.intersperse(" - ") + |> Enum.join() + m.replyfun.("Corona virus top 10 #{name}: " <> sorted) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: location}}}, state) do + location = Enum.join(location, " ") |> String.downcase() + if data = Map.get(state.data, location) do + m.replyfun.("Corona virus: #{location}: #{data.current} malades, #{data.confirmed} confirmés, #{data.deaths} morts, #{data.recovered} soignés") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :query, args: location}}}, state) do + m.replyfun.("https://github.com/CSSEGISandData/COVID-19") + {:noreply, state} + end + + # 1. Try to fetch data for today + # 2. Fetch yesterday if no results + defp fetch_data(current_data, date \\ nil) do + now = Date.utc_today() + url = fn(date) -> + "https://github.com/CSSEGISandData/COVID-19/raw/master/csse_covid_19_data/csse_covid_19_daily_reports/#{date}.csv" + end + request_date = date || now + Logger.debug("Coronavirus check date: #{inspect request_date}") + {:ok, date_s} = Timex.format({request_date.year, request_date.month, request_date.day}, "%m-%d-%Y", :strftime) + cur_url = url.(date_s) + Logger.debug "Fetching URL #{cur_url}" + case HTTPoison.get(cur_url, [], follow_redirect: true) do + {:ok, %HTTPoison.Response{status_code: 200, body: csv}} -> + # Parse CSV update data + data = csv + |> String.strip() + |> String.split("\n") + |> Enum.drop(1) + |> Enum.reduce(%{}, fn(line, acc) -> + [state, region, update, confirmed, deaths, recovered,_lat, _lng] = line + |> String.strip() + |> String.split(",") + + state = String.downcase(state) + region = String.downcase(region) + confirmed = String.to_integer(confirmed) + deaths = String.to_integer(deaths) + recovered = String.to_integer(recovered) + + current = (confirmed - recovered) - deaths + + entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region} + + acc = if state && state != "" do + Map.put(acc, state, entry) + else + acc + end + + region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0}) + region_entry = %{ + update: region_entry.update || update, + confirmed: region_entry.confirmed + confirmed, + deaths: region_entry.deaths + deaths, + current: region_entry.current + current, + recovered: region_entry.recovered + recovered, + region: true + } + + Map.put(acc, region, region_entry) + end) + Logger.info "Updated coronavirus database" + {data, :timer.minutes(60)} + {:ok, %HTTPoison.Response{status_code: 404}} -> + Logger.debug "Corona 404 #{cur_url}" + date = Date.add(date || now, -1) + fetch_data(current_data, date) + other -> + Logger.error "Coronavirus: Update failed #{inspect other}" + {current_data, :timer.minutes(5)} + end + end + +end + diff --git a/lib/lsg_irc/dice_handler.ex b/lib/lsg_irc/dice_handler.ex deleted file mode 100644 index 7ff7b4d..0000000 --- a/lib/lsg_irc/dice_handler.ex +++ /dev/null @@ -1,73 +0,0 @@ -defmodule LSG.IRC.DiceHandler do - require Logger - - @moduledoc """ - # dice - - * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces - """ - - @default_faces 6 - @default_rolls 1 - @max_rolls 50 - - def short_irc_doc, do: "!dice (jeter un dé)" - defstruct client: nil, dets: nil - - def irc_doc, do: @moduledoc - - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler(client, self()) - {:ok, _} = Registry.register(IRC.PubSub, "dice", []) - {:ok, %__MODULE__{client: client}} - end - - def handle_info({:received, "!dice", sender, chan}, state) do - roll(state, sender, chan, @default_faces, @default_rolls) - {:noreply, state} - end - - def handle_info({:received, "!dice "<>params, sender, chan}, state) do - {rolls, faces} = case String.split(params, " ", parts: 2) do - [faces, rolls] -> {rolls, faces} - [rolls] -> {rolls, @default_faces} - end - - to_integer = fn(string, default) -> - case Integer.parse(string) do - {int, _} -> int - _ -> default - end - end - - {faces, rolls} = {to_integer.(faces, @default_faces), to_integer.(rolls, @default_rolls)} - - roll(state, sender, chan, faces, rolls) - - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - defp roll(state, %{nick: nick}, chan, faces, 1) when faces > 0 do - random = :crypto.rand_uniform(1, faces+1) - ExIRC.Client.msg(state.client, :privmsg, chan, "#{nick} dice: #{random}") - end - defp roll(state, %{nick: nick}, chan, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do - {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> - random = :crypto.rand_uniform(1, faces+1) - {random, acc + random} - end) - results = Enum.join(results, "; ") - ExIRC.Client.msg(state.client, :privmsg, chan, "#{nick} dice [#{acc}] #{results}") - end - - defp roll(_, _, _, _, _), do: nil - -end diff --git a/lib/lsg_irc/dice_plugin.ex b/lib/lsg_irc/dice_plugin.ex new file mode 100644 index 0000000..a507b8e --- /dev/null +++ b/lib/lsg_irc/dice_plugin.ex @@ -0,0 +1,66 @@ +defmodule LSG.IRC.DicePlugin do + require Logger + + @moduledoc """ + # dice + + * **!dice `[1 | lancés]` `[6 | faces]`**: lance une ou plusieurs fois un dé de 6 ou autre faces + """ + + @default_faces 6 + @default_rolls 1 + @max_rolls 50 + + def short_irc_doc, do: "!dice (jeter un dé)" + defstruct client: nil, dets: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", []) + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: args}}}, state) do + to_integer = fn(string, default) -> + case Integer.parse(string) do + {int, _} -> int + _ -> default + end + end + + {rolls, faces} = case args do + [] -> {@default_rolls, @default_faces} + [faces, rolls] -> {to_integer.(rolls, @default_rolls), to_integer.(faces, @default_faces)} + [rolls] -> {to_integer.(rolls, @default_rolls), @default_faces} + end + + roll(state, message, faces, rolls) + + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + defp roll(state, message, faces, 1) when faces > 0 do + random = :crypto.rand_uniform(1, faces+1) + message.replyfun.("#{message.sender.nick} dice: #{random}") + end + defp roll(state, message, faces, rolls) when faces > 0 and rolls > 0 and rolls <= @max_rolls do + {results, acc} = Enum.map_reduce(Range.new(1, rolls), 0, fn(i, acc) -> + random = :crypto.rand_uniform(1, faces+1) + {random, acc + random} + end) + results = Enum.join(results, "; ") + message.replyfun.("#{message.sender.nick} dice: [#{acc}] #{results}") + end + + defp roll(_, _, _, _, _), do: nil + +end diff --git a/lib/lsg_irc/finance_plugin.ex b/lib/lsg_irc/finance_plugin.ex new file mode 100644 index 0000000..7266a5e --- /dev/null +++ b/lib/lsg_irc/finance_plugin.ex @@ -0,0 +1,189 @@ +defmodule LSG.IRC.FinancePlugin do + require Logger + + @moduledoc """ + # finance + + Données de [alphavantage.co](https://alphavantage.co). + + ## forex / monnaies / crypto-monnaies + + * **`!forex <MONNAIE1> [MONNAIE2]`**: taux de change entre deux monnaies. + * **`!forex <MONTANT> <MONNAIE1> <MONNAIE2>`**: converti `montant` entre deux monnaies + * **`?currency <recherche>`**: recherche une monnaie + + Utiliser le symbole des monnaies (EUR, USD, ...). + + ## bourses + + * **`!stocks <SYMBOLE>`** + * **`?stocks <recherche>`** cherche un symbole + + Pour les symboles non-US, ajouter le suffixe (RNO Paris: RNO.PAR). + + """ + + @currency_list "https://www.alphavantage.co/physical_currency_list/" + @crypto_list "https://www.alphavantage.co/digital_currency_list/" + + HTTPoison.start() + load_currency = fn(url) -> + resp = HTTPoison.get!(url) + resp.body + |> String.strip() + |> String.split("\n") + |> Enum.drop(1) + |> Enum.map(fn(line) -> + [symbol, name] = line + |> String.strip() + |> String.split(",", parts: 2) + {symbol, name} + end) + |> Enum.into(Map.new) + end + fiat = load_currency.(@currency_list) + crypto = load_currency.(@crypto_list) + @currencies Map.merge(fiat, crypto) + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:forex", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", []) + {:ok, nil} + end + + + def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :query, args: args = search}}}, state) do + search = Enum.join(search, "%20") + url = "https://www.alphavantage.co/query?function=SYMBOL_SEARCH&keywords=#{search}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("stocks: requête invalide") + else + items = for item <- Map.get(data, "bestMatches") do + symbol = Map.get(item, "1. symbol") + name = Map.get(item, "2. name") + type = Map.get(item, "3. type") + region = Map.get(item, "4. region") + currency = Map.get(item, "8. currency") + "#{symbol}: #{name} (#{region}; #{currency}; #{type})" + end + |> Enum.join(", ") + items = if items == "" do + "no results!" + else + items + end + message.replyfun.(items) + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("forex: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("forex: erreur (http #{inspect error})") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "stocks", message = %{trigger: %{type: :bang, args: args = [symbol]}}}, state) do + url = "https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=#{symbol}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("stocks: requête invalide") + else + data = Map.get(data, "Global Quote") + open = Map.get(data, "02. open") + high = Map.get(data, "03. high") + low = Map.get(data, "04. low") + price = Map.get(data, "05. price") + volume = Map.get(data, "06. volume") + prev_close = Map.get(data, "08. previous close") + change = Map.get(data, "09. change") + change_pct = Map.get(data, "10. change percent") + + msg = "#{symbol}: #{price} #{change} [#{change_pct}] (high: #{high}, low: #{low}, open: #{open}, prev close: #{prev_close}) (volume: #{volume})" + message.replyfun.(msg) + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("stocks: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("stocks: erreur (http #{inspect error})") + end + {:noreply, state} + end + + + def handle_info({:irc, :trigger, "forex", message = %{trigger: %{type: :bang, args: args = [_ | _]}}}, state) do + {amount, from, to} = case args do + [amount, from, to] -> + {amount, _} = Float.parse(amount) + {amount, from, to} + [from, to] -> + {1, from, to} + [from] -> + {1, from, "EUR"} + end + url = "https://www.alphavantage.co/query?function=CURRENCY_EXCHANGE_RATE&from_currency=#{from}&to_currency=#{to}&apikey=#{api_key()}" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: data}} -> + data = Poison.decode!(data) + if error = Map.get(data, "Error Message") do + Logger.error("AlphaVantage API invalid request #{url} - #{inspect error}") + message.replyfun.("forex: requête invalide") + else + data = Map.get(data, "Realtime Currency Exchange Rate") + from_name = Map.get(data, "2. From_Currency Name") + to_name = Map.get(data, "4. To_Currency Name") + rate = Map.get(data, "5. Exchange Rate") + {rate, _} = Float.parse(rate) + value = amount*rate + message.replyfun.("#{amount} #{from} (#{from_name}) -> #{value} #{to} (#{to_name}) (#{rate})") + end + {:ok, resp = %HTTPoison.Response{status_code: code}} -> + Logger.error "AlphaVantage API error: #{code} #{url} - #{inspect resp}" + message.replyfun.("forex: erreur (api #{code})") + {:error, %HTTPoison.Error{reason: error}} -> + Logger.error "AlphaVantage HTTP error: #{inspect error}" + message.replyfun.("forex: erreur (http #{inspect error})") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "currency", message = %{trigger: %{type: :query, args: args = search}}}, state) do + search = Enum.join(search, " ") + results = Enum.filter(@currencies, fn({symbol, name}) -> + String.contains?(String.downcase(name), String.downcase(search)) || String.contains?(String.downcase(symbol), String.downcase(search)) + end) + |> Enum.map(fn({symbol, name}) -> + "#{symbol}: #{name}" + end) + |> Enum.join(", ") + + if results == "" do + message.replyfun.("no results!") + else + message.replyfun.(results) + end + {:noreply, state} + end + + defp api_key() do + Application.get_env(:lsg, :alphavantage, []) + |> Keyword.get(:api_key, "demo") + end + +end diff --git a/lib/lsg_irc/kick_roulette_handler.ex b/lib/lsg_irc/kick_roulette_handler.ex deleted file mode 100644 index ece1b95..0000000 --- a/lib/lsg_irc/kick_roulette_handler.ex +++ /dev/null @@ -1,32 +0,0 @@ -defmodule LSG.IRC.KickRouletteHandler do - @moduledoc """ - # kick roulette - - * **!kick** - """ - - def irc_doc, do: @moduledoc - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler client, self - {:ok, client} - end - - def handle_info({:received, "!kick", sender, chan}, client) do - if 5 == :crypto.rand_uniform(1, 6) do - spawn(fn() -> - :timer.sleep(:crypto.rand_uniform(200, 10_000)) - ExIRC.Client.kick(client, chan, sender.nick, "perdu") - end) - end - {:noreply, client} - end - - def handle_info(msg, client) do - {:noreply, client} - end - -end diff --git a/lib/lsg_irc/kick_roulette_plugin.ex b/lib/lsg_irc/kick_roulette_plugin.ex new file mode 100644 index 0000000..83efcb0 --- /dev/null +++ b/lib/lsg_irc/kick_roulette_plugin.ex @@ -0,0 +1,32 @@ +defmodule LSG.IRC.KickRoulettePlugin do + @moduledoc """ + # kick roulette + + * **!kick**, tentez votre chance… + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", []) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "kick", message = %{trigger: %{type: :bang, args: []}}}, _) do + if 5 == :crypto.rand_uniform(1, 6) do + spawn(fn() -> + :timer.sleep(:crypto.rand_uniform(200, 10_000)) + message.replyfun.({:kick, message.sender.nick, "perdu"}) + end) + end + {:noreply, nil} + end + + def handle_info(msg, _) do + {:noreply, nil} + end + +end diff --git a/lib/lsg_irc/last_fm_handler.ex b/lib/lsg_irc/last_fm_plugin.ex index 4eef24e..2446473 100644 --- a/lib/lsg_irc/last_fm_handler.ex +++ b/lib/lsg_irc/last_fm_plugin.ex @@ -1,4 +1,4 @@ -defmodule LSG.IRC.LastFmHandler do +defmodule LSG.IRC.LastFmPlugin do require Logger @moduledoc """ @@ -9,54 +9,54 @@ defmodule LSG.IRC.LastFmHandler do * **+lastfm `<username last.fm>`, -lastfm** """ - defstruct client: nil, dets: nil + defstruct dets: nil def irc_doc, do: @moduledoc - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) + def start_link() do + GenServer.start_link(__MODULE__, []) end - def init([client]) do - ExIRC.Client.add_handler(client, self()) + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:lastfm", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:lastfmall", []) dets_filename = (LSG.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, []) - {:ok, %__MODULE__{client: client, dets: dets}} + {:ok, %__MODULE__{dets: dets}} end - def handle_info({:received, "+lastfm " <> username, sender, chan}, state) do + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do username = String.strip(username) - :ok = :dets.insert(state.dets, {String.downcase(sender.nick), username}) - ExIRC.Client.msg(state.client, :privmsg, chan, "#{sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\"") + :ok = :dets.insert(state.dets, {String.downcase(message.sender.nick), username}) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") {:noreply, state} end - def handle_info({:received, "-lastfm", sender, chan}, state) do - text = case :dets.lookup(state.dets, sender.nick) do + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do + text = case :dets.lookup(state.dets, message.sender.nick) do [{_nick, username}] -> - :dets.delete(state.dets, String.downcase(sender.nick)) - "#{sender.nick}: nom d'utilisateur last.fm enlevé" - _ -> "" + :dets.delete(state.dets, String.downcase(message.sender.nick)) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") + _ -> nil end - ExIRC.Client.msg(state.client, :privmsg, chan, text) {:noreply, state} end - def handle_info({:received, "!lastfm", sender, chan}, state) do - irc_now_playing(sender.nick, chan, state) + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :bang, args: []}}}, state) do + irc_now_playing(message.sender.nick, message, state) {:noreply, state} end - def handle_info({:received, "!lastfm " <> nick_or_user, sender, chan}, state) do - irc_now_playing(nick_or_user, chan, state) + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do + irc_now_playing(nick_or_user, message, state) {:noreply, state} end - def handle_info({:received, "!lastfmall", sender, chan}, state) do + def handle_info({:irc, :trigger, "lastfmall", message = %{trigger: %{type: :bang}}}, state) do foldfun = fn({_nick, user}, acc) -> [user|acc] end usernames = :dets.foldl(foldfun, [], state.dets) |> Enum.uniq - for u <- usernames, do: irc_now_playing(u, chan, state) + for u <- usernames, do: irc_now_playing(u, message, state) {:noreply, state} end @@ -72,7 +72,7 @@ defmodule LSG.IRC.LastFmHandler do :ok end - defp irc_now_playing(nick_or_user, chan, state) do + defp irc_now_playing(nick_or_user, message, state) do nick_or_user = String.strip(nick_or_user) username = case :dets.lookup(state.dets, String.downcase(nick_or_user)) do [{^nick_or_user, username}] -> username @@ -80,19 +80,23 @@ defmodule LSG.IRC.LastFmHandler do end case now_playing(username) do - {:error, text} when is_binary(text) -> ExIRC.Client.msg(state.client, :privmsg, chan, text) + {:error, text} when is_binary(text) -> + message.replyfun.(text) {:ok, map} when is_map(map) -> text = format_now_playing(map) user = lookup_nick(username, state) - if user && text, do: ExIRC.Client.msg(state.client, :privmsg, chan, "#{user} #{text}") + if user && text do + message.replyfun.("#{user} #{text}") + else + message.replyfun.("#{username}: pas de résultat") + end other -> - IO.inspect(other) - nil + message.replyfun.("erreur http") end end defp now_playing(user) do - api = Application.get_env(:lsg, __MODULE__)[:api_key] + api = Application.get_env(:lsg, :lastfm)[:api_key] url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1" <> "&api_key=" <> api <> "&user="<> user case HTTPoison.get(url) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) diff --git a/lib/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex new file mode 100644 index 0000000..61bdbf9 --- /dev/null +++ b/lib/lsg_irc/link_plugin.ex @@ -0,0 +1,172 @@ +defmodule LSG.IRC.LinkPlugin do + @moduledoc """ + # Link Previewer + + An extensible link previewer for IRC. + + To extend the supported sites, create a new handler implementing the callbacks. + + See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, + and if the handler returns `:error` or crashes, will fallback to the default preview. + + Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use + the mimetype and size. + + ## Configuration: + + ``` + config :lsg, LSG.IRC.LinkPlugin, + handlers: [ + LSG.IRC.LinkPlugin.Youtube: [ + invidious: true + ], + LSG.IRC.LinkPlugin.Twitter: [], + LSG.IRC.LinkPlugin.Imgur: [], + ] + ``` + + """ + + @ircdoc """ + # Link preview + + Previews links (just post a link!). + + Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. + """ + def short_irc_doc, do: false + def irc_doc, do: @ircdoc + require Logger + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + @callback match(uri :: URI.t, options :: Keyword.t) :: {true, params :: Map.t} | false + @callback expand(uri :: URI.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + + defstruct [:client] + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "message", []) + Logger.info("Link handler started") + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :text, message = %{text: text}}, state) do + String.split(text) + |> Enum.map(fn(word) -> + if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do + uri = URI.parse(word) + if uri.scheme && uri.host do + spawn(fn() -> + case expand_link([uri]) do + {:ok, uris, text} -> + text = case uris do + [uri] -> text + [uri | _] -> ["-> #{URI.to_string(uri)}", text] + end + message.replyfun.(text) + _ -> nil + end + end) + end + end + end) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + :ok + end + + # 1. Match the first valid handler + # 2. Try to run the handler + # 3. If :error or crash, default link. + # If :skip, nothing + # 4. ? + + # Over five redirections: cancel. + def expand_link([_, _, _, _, _, _ | _]) do + :error + end + + def expand_link(acc=[uri | _]) do + handlers = Keyword.get(Application.get_env(:lsg, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> + module = Module.concat([module]) + case module.match(uri, opts) do + {true, params} -> {:halt, {module, params, opts}} + false -> {:cont, acc} + end + end) + run_expand(acc, handler) + end + + def run_expand(acc, nil) do + expand_default(acc) + end + + def run_expand(acc=[uri|_], {module, params, opts}) do + case module.expand(uri, params, opts) do + {:ok, data} -> {:ok, acc, data} + :error -> expand_default(acc) + :skip -> nil + end + rescue + e -> + Logger.error(inspect(e)) + expand_default(acc) + catch + e, b -> + Logger.error(inspect({b})) + expand_default(acc) + end + + def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do + headers = [] + options = [follow_redirect: false, max_body_length: 30_000_000] + case HTTPoison.get(URI.to_string(uri), headers, options) do + {:ok, %HTTPoison.Response{status_code: 200, headers: headers, body: body}} -> + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + text = case Map.get(headers, "content-type") do + "text/html"<>_ -> + html = Floki.parse(body) + case Floki.find(html, "title") do + [{"title", [], [title]} | _] -> + title + _ -> + nil + end + other -> + "file: #{other}, size: #{Map.get(headers, "content-length", "?")} bytes" + end + {:ok, acc, text} + {:ok, resp = %HTTPoison.Response{headers: headers, status_code: redirect, body: body}} when redirect in 300..399 -> + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + link = Map.get(headers, "location") + new_uri = URI.parse(link) + expand_link([new_uri | acc]) + {:ok, %HTTPoison.Response{status_code: code}} -> + {:ok, acc, "Error #{code}"} + {:error, %HTTPoison.Error{reason: reason}} -> + {:ok, acc, "Error #{to_string(reason)}"} + {:error, error} -> + {:ok, acc, "Error #{inspect(error)}"} + end + end + + # Unsupported scheme, came from a redirect. + def expand_default(acc = [uri | _]) do + {:ok, [uri], "-> #{URI.to_string(uri)}"} + end + +end diff --git a/lib/lsg_irc/link_plugin/imgur.ex b/lib/lsg_irc/link_plugin/imgur.ex new file mode 100644 index 0000000..9ce3cf3 --- /dev/null +++ b/lib/lsg_irc/link_plugin/imgur.ex @@ -0,0 +1,84 @@ +defmodule LSG.IRC.LinkPlugin.Imgur do + @behaviour LSG.IRC.LinkPlugin + + @moduledoc """ + # Imgur link preview + + No options. + + Needs to have a Imgur API key configured: + + ``` + config :lsg, :imgur, + client_id: "xxxxxxxx", + client_secret: "xxxxxxxxxxxxxxxxxxxx" + ``` + """ + + def match(uri = %URI{host: "imgur.com", path: "/a/"<>album_id}, _) do + {true, %{album_id: album_id}} + end + def match(uri = %URI{host: "imgur.com", path: "/gallery/"<>album_id}, _) do + {true, %{album_id: album_id}} + end + def match(uri = %URI{host: "i.imgur.com", path: "/"<>image}, _) do + [hash, _] = String.split(image, ".", parts: 2) + {true, %{image_id: hash}} + end + def match(_, _), do: false + + def expand(_uri, %{album_id: album_id}, opts) do + expand_imgur_album(album_id, opts) + end + + def expand(_uri, %{image_id: image_id}, opts) do + expand_imgur_image(image_id, opts) + end + + def expand_imgur_image(image_id, opts) do + client_id = Keyword.get(Application.get_env(:lsg, :imgur, []), :client_id, "42") + headers = [{"Authorization", "Client-ID #{client_id}"}] + options = [] + case HTTPoison.get("https://api.imgur.com/3/image/#{image_id}", headers, options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + data = json["data"] + IO.puts inspect(json) + title = String.slice(data["title"] || data["description"], 0, 180) + nsfw = if data["nsfw"], do: "(NSFW) - ", else: "" + {:ok, "#{nsfw}#{title}"} + other -> + :error + end + end + + def expand_imgur_album(album_id, opts) do + client_id = Keyword.get(Application.get_env(:lsg, :imgur, []), :client_id, "42") + headers = [{"Authorization", "Client-ID #{client_id}"}] + options = [] + case HTTPoison.get("https://api.imgur.com/3/album/#{album_id}", headers, options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + data = json["data"] + title = data["title"] + nsfw = data["nsfw"] + nsfw = if nsfw, do: "(NSFW) - ", else: "" + if data["images_count"] == 1 do + [image] = data["images"] + title = if title || data["title"] do + title = [title, data["title"]] |> Enum.filter(fn(x) -> x end) |> Enum.uniq() |> Enum.join(" — ") + "#{title} — " + else + "" + end + {:ok, "#{nsfw}#{title}#{image["link"]}"} + else + title = if title, do: title, else: "Untitled album" + {:ok, "#{nsfw}#{title} - #{data["images_count"]} images"} + end + other -> + :error + end + end + +end diff --git a/lib/lsg_irc/link_plugin/twitter.ex b/lib/lsg_irc/link_plugin/twitter.ex new file mode 100644 index 0000000..04dea7c --- /dev/null +++ b/lib/lsg_irc/link_plugin/twitter.ex @@ -0,0 +1,91 @@ +defmodule LSG.IRC.LinkPlugin.Twitter do + @behaviour LSG.IRC.LinkPlugin + + @moduledoc """ + # Twitter Link Preview + + Configuration: + + needs an API key and auth tokens: + + ``` + config :extwitter, :oauth, [ + consumer_key: "zzzzz", + consumer_secret: "xxxxxxx", + access_token: "yyyyyy", + access_token_secret: "ssshhhhhh" + ] + ``` + + options: + + * `expand_quoted`: Add the quoted tweet instead of its URL. Default: true. + """ + + def match(uri = %URI{host: twitter, path: path}, _opts) when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do + case String.split(path, "/", parts: 4) do + ["", _username, "status", status_id] -> + {status_id, _} = Integer.parse(status_id) + {true, %{status_id: status_id}} + _ -> false + end + end + + def match(_, _), do: false + + def expand(_uri, %{status_id: status_id}, opts) do + expand_tweet(ExTwitter.show(status_id, tweet_mode: "extended"), opts) + end + + defp expand_tweet(nil, _opts) do + :error + end + + defp expand_tweet(tweet, opts) do + text = expand_twitter_text(tweet) + text = if tweet.quoted_status do + quote_url = "https://twitter.com/#{tweet.quoted_status.user.screen_name}/status/#{tweet.quoted_status.id}" + String.replace(text, quote_url, "") + else + text + end + + text = IRC.splitlong(text) + + {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) + {:ok, format} = Timex.format(at, "{relative}", :relative) + + quoted = if tweet.quoted_status do + quote_url = "https://twitter.com/#{tweet.quoted_status.user.screen_name}/status/#{tweet.quoted_status.id}" + full_text = expand_twitter_text(tweet.quoted_status) + |> IRC.splitlong_with_prefix(">") + ["> #{tweet.quoted_status.user.name} (@#{tweet.quoted_status.user.screen_name}): #{quote_url}"] ++ full_text + else + [] + end + + foot = "— #{format} - #{tweet.retweet_count} retweets - #{tweet.favorite_count} likes" + + text = ["#{tweet.user.name} (@#{tweet.user.screen_name}):"] ++ text ++ quoted ++ [foot] + {:ok, text} + end + + defp expand_twitter_text(tweet) do + text = Enum.reduce(tweet.entities.urls, tweet.full_text, fn(entity, text) -> + String.replace(text, entity.url, entity.expanded_url) + end) + extended = tweet.extended_entities || %{media: []} + text = Enum.reduce(extended.media, text, fn(entity, text) -> + url = Enum.filter(extended.media, fn(e) -> entity.url == e.url end) + |> Enum.map(fn(e) -> + cond do + e.type == "video" -> e.expanded_url + true -> e.media_url_https + end + end) + |> Enum.join(" ") + String.replace(text, entity.url, url) + end) + end + +end diff --git a/lib/lsg_irc/link_plugin/youtube.ex b/lib/lsg_irc/link_plugin/youtube.ex new file mode 100644 index 0000000..ea4f213 --- /dev/null +++ b/lib/lsg_irc/link_plugin/youtube.ex @@ -0,0 +1,69 @@ +defmodule LSG.IRC.LinkPlugin.YouTube do + @behaviour LSG.IRC.LinkPlugin + + @moduledoc """ + # YouTube link preview + + needs an API key: + + ``` + config :lsg, :youtube, + api_key: "xxxxxxxxxxxxx" + ``` + + options: + + * `invidious`: Add a link to invidio.us. Default: true. + """ + + def match(uri = %URI{host: yt, path: "/watch", query: "v="<>video_id}, _opts) when yt in ["youtube.com", "www.youtube.com"] do + {true, %{video_id: video_id}} + end + + def match(%URI{host: "youtu.be", path: "/"<>video_id}, _opts) do + {true, %{video_id: video_id}} + end + + def match(_, _), do: false + + + def expand(uri, %{video_id: video_id}, opts) do + key = Application.get_env(:lsg, :youtube)[:api_key] + params = %{ + "part" => "snippet,contentDetails,statistics", + "id" => video_id, + "key" => key + } + headers = [] + options = [params: params] + case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case Jason.decode(body) do + {:ok, json} -> + item = List.first(json["items"]) + if item do + snippet = item["snippet"] + duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase + date = snippet["publishedAt"] + |> DateTime.from_iso8601() + |> elem(1) + |> Timex.format("{relative}", :relative) + |> elem(1) + + line = if Keyword.get(opts, :invidious, true) do + ["-> https://invidio.us/watch?v=#{video_id}"] + else + [] + end + {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" + <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," + <> " #{item["statistics"]["dislikeCount"]} dislikes"]} + else + :error + end + _ -> :error + end + end + end + +end diff --git a/lib/lsg_irc/outline_plugin.ex b/lib/lsg_irc/outline_plugin.ex new file mode 100644 index 0000000..7bfaac1 --- /dev/null +++ b/lib/lsg_irc/outline_plugin.ex @@ -0,0 +1,72 @@ +defmodule LSG.IRC.OutlinePlugin do + @moduledoc """ + # outline auto-link + + Envoie un lien vers Outline quand un lien est envoyé. + + * **+outline `<host>`** active outline pour `<host>`. + * **-outline `<host>`** désactive outline pour `<host>`. + """ + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + require Logger + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + defstruct [:file, :hosts] + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", []) + {:ok, _} = Registry.register(IRC.PubSub, "message", []) + file = Path.join(LSG.data_path, "/outline.txt") + hosts = case File.read(file) do + {:error, :enoent} -> + [] + {:ok, lines} -> + String.split(lines, "\n", trim: true) + end + {:ok, %__MODULE__{file: file, hosts: hosts}} + end + + def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do + state = %{state | hosts: [host | state.hosts]} + save(state) + message.replyfun.("ok") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do + state = %{state | hosts: List.delete(state.hosts, host)} + save(state) + message.replyfun.("ok") + {:noreply, state} + end + + def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do + String.split(text) + |> Enum.map(fn(word) -> + if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do + uri = URI.parse(word) + if uri.scheme && uri.host do + if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do + line = "-> https://outline.com/#{word}" + message.replyfun.(line) + end + end + end + end) + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + def save(state = %{file: file, hosts: hosts}) do + string = Enum.join(hosts, "\n") + File.write(file, string) + end + +end diff --git a/lib/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex new file mode 100644 index 0000000..98cd539 --- /dev/null +++ b/lib/lsg_irc/preums_plugin.ex @@ -0,0 +1,108 @@ +defmodule LSG.IRC.PreumsPlugin do + @moduledoc """ + # preums !!! + + * `!preums`: affiche le preums du jour + * `.preums`: stats des preums + """ + + @perfects [~r/preum(s|)/i] + + # dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text} + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "message", []) + {:ok, _} = Registry.register(IRC.PubSub, "triggers", []) + dets_filename = (LSG.data_path() <> "/preums.dets") |> String.to_charlist() + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %{dets: dets}} + end + + # Latest + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{channel: channel, trigger: %IRC.Trigger{type: :bang}}}, state) do + state = handle_preums(m, state) + tz = timezone(channel) + {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) + date = {now.year, now.month, now.day} + key = {channel, date} + chan_cache = Map.get(state, channel, %{}) + item = if i = Map.get(chan_cache, date) do + i + else + case :dets.lookup(state.dets, key) do + [item = {^key, _nick, _now, _perfect, _text}] -> item + _ -> nil + end + end + + if item do + {_, nick, date, _perfect, text} = item + h = "#{date.hour}:#{date.minute}:#{date.second}" + m.replyfun.("preums: #{nick} à #{h}: “#{text}”") + end + {:noreply, state} + end + + # Stats + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{channel: channel, trigger: %IRC.Trigger{type: :dot}}}, state) do + state = handle_preums(m, state) + {:noreply, state} + end + + # Help + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{channel: channel, trigger: %IRC.Trigger{type: :query}}}, state) do + state = handle_preums(m, state) + {:noreply, state} + end + + # Trigger fallback + def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do + state = handle_preums(m, state) + {:noreply, state} + end + + # Message fallback + def handle_info({:irc, :text, m = %IRC.Message{}}, state) do + {:noreply, handle_preums(m, state)} + end + + defp timezone(channel) do + env = Application.get_env(:lsg, LSG.IRC.PreumsPlugin, []) + channels = Keyword.get(env, :channels, %{}) + channel_settings = Map.get(channels, channel, []) + default = Keyword.get(env, :default_tz, "Europe/Paris") + Keyword.get(channel_settings, :tz, default) || default + end + + defp handle_preums(m = %IRC.Message{channel: channel, text: text, sender: sender}, state) do + tz = timezone(channel) + {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) + date = {now.year, now.month, now.day} + key = {channel, date} + chan_cache = Map.get(state, channel, %{}) + unless i = Map.get(chan_cache, date) do + case :dets.lookup(state.dets, key) do + [item = {^key, _nick, _now, _perfect, _text}] -> + # Preums lost, but wasn't cached + state = Map.put(state, channel, %{date => item}) + state + _ -> + # Preums won! + perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) + item = {key, sender.nick, now, perfect?, text} + :dets.insert(state.dets, item) + :dets.sync(state.dets) + state = Map.put(state, channel, %{date => item}) + state + end + else + state + end + end + +end diff --git a/lib/lsg_irc/quatre_cent_vingt_plugin.ex b/lib/lsg_irc/quatre_cent_vingt_plugin.ex index 7c3067c..f6f8a63 100644 --- a/lib/lsg_irc/quatre_cent_vingt_plugin.ex +++ b/lib/lsg_irc/quatre_cent_vingt_plugin.ex @@ -5,6 +5,7 @@ defmodule LSG.IRC.QuatreCentVingtPlugin do # 420 * **!420**: recorde un nouveau 420. + * **!420*x**: recorde un nouveau 420*x (*2 = 840, ...) (à vous de faire la multiplication). * **!420 pseudo**: stats du pseudo. """ diff --git a/lib/lsg_irc/txt_handler.ex b/lib/lsg_irc/txt_plugin.ex index 032c11c..6b7edbd 100644 --- a/lib/lsg_irc/txt_handler.ex +++ b/lib/lsg_irc/txt_plugin.ex @@ -1,101 +1,110 @@ -defmodule LSG.IRC.TxtHandler do +defmodule LSG.IRC.TxtPlugin do alias IRC.UserTrack require Logger @moduledoc """ # [txt](/irc/txt) - * **!txt**: liste des fichiers et statistiques. + * **.txt**: liste des fichiers et statistiques. Les fichiers avec une `*` sont vérrouillés. [Voir sur le web](/irc/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`. - * **!`FILE` `<recherche>`**: recherche une ligne contenant `<recherche>` dans `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`. - * **+txtro, +txtrw**. op seulement. active/désactive le mode lecture seule. + * **-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(client) do - GenServer.start_link(__MODULE__, [client]) + def start_link() do + GenServer.start_link(__MODULE__, []) end - defstruct client: nil, triggers: %{}, rw: true, locks: nil, markov: nil + defstruct triggers: %{}, rw: true, locks: nil, markov_handler: nil, markov: nil - def init([client]) do + def init([]) do dets_locks_filename = (LSG.data_path() <> "/" <> "txtlocks.dets") |> String.to_charlist {:ok, locks} = :dets.open_file(dets_locks_filename, []) - {:ok, markov} = ExChain.MarkovModel.start_link - state = %__MODULE__{client: client, locks: locks, markov: markov} - ExIRC.Client.add_handler(client, self()) - {:ok, %__MODULE__{state | triggers: load()}} + markov_handler = Keyword.get(Application.get_env(:lsg, __MODULE__, []), :markov_handler, LSG.IRC.TxtPlugin.Markov.Native) + {:ok, markov} = markov_handler.start_link() + {:ok, _} = Registry.register(IRC.PubSub, "triggers", []) + {: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 RO/RW - def handle_info({:received, "+txtrw", %ExIRC.SenderInfo{nick: nick}, chan}, state = %__MODULE__{rw: false}) do - if UserTrack.operator?(chan, nick) do - say(state, chan, "txt: écriture réactivée") + # + # ADMIN: RW/RO + # + + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :plus}}}, state = %{rw: false}) do + if channel && UserTrack.operator?(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({:received, "+txtro", %ExIRC.SenderInfo{nick: nick}, chan}, state = %__MODULE__{rw: true}) do - if UserTrack.operator?(chan, nick) do - say(state, chan, "txt: écriture désactivée") + def handle_info({:irc, :trigger, "txtrw", msg = %{channel: channel, trigger: %{type: :minus}}}, state = %{rw: true}) do + if channel && UserTrack.operator?(channel, msg.sender.nick) do + msg.replyfun.("txt: écriture désactivée") {:noreply, %__MODULE__{state | rw: false}} else {:noreply, state} end end - def handle_info({:received, "+txtro", _, _}, state) do - {:noreply, state} - end - def handle_info({:received, "+txtrw", _, _}, state) do - {:noreply, state} - end + # + # ADMIN: LOCKS + # - ## -- ADMIN LOCKS - def handle_info({:received, "+txtlock " <> trigger, %ExIRC.SenderInfo{nick: nick}, chan}, state) do + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :plus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(chan, nick) + true <- UserTrack.operator?(msg.channel, msg.sender.nick) do :dets.insert(state.locks, {trigger}) - say(state, chan, "txt: #{trigger} verrouillé") + msg.replyfun.("txt: #{trigger} verrouillé") end {:noreply, state} end - def handle_info({:received, "-txtlock " <> trigger, %ExIRC.SenderInfo{nick: nick}, chan}, state) do + def handle_info({:irc, :trigger, "txtlock", msg = %{trigger: %{type: :minus, args: [trigger]}}}, state) do with \ {trigger, _} <- clean_trigger(trigger), - true <- UserTrack.operator?(chan, nick), + true <- UserTrack.operator?(msg.channel, msg.sender.nick), true <- :dets.member(state.locks, trigger) do :dets.delete(state.locks, trigger) - say(state, chan, "txt: #{trigger} déverrouillé") + msg.replyfun.("txt: #{trigger} déverrouillé") end {:noreply, state} end - # -- ADD + # + # FILE LIST + # - def handle_info({:received, "!txt", _, chan}, state) do + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :dot}}}, state) do map = Enum.map(state.triggers, fn({key, data}) -> locked? = case :dets.lookup(state.locks, key) do [{trigger}] -> "*" @@ -113,103 +122,147 @@ defmodule LSG.IRC.TxtHandler do ro = if !state.rw, do: " (lecture seule activée)", else: "" (detail<>total<>ro) - |> String.codepoints - |> Enum.chunk_every(440) - |> Enum.map(&Enum.join/1) - |> Enum.map(fn(line) -> ExIRC.Client.msg(state.client, :privmsg, chan, line) end) + |> msg.replyfun.() {:noreply, state} end - def handle_info({:received, "~txt", _, chan}, state) do - case ExChain.SentenceGenerator.create_filtered_sentence(state.markov) do - {:ok, line, _, _} -> - ExIRC.Client.msg(state.client, :privmsg, chan, line) - error -> - Logger.error "Txt Markov error: "<>inspect error + # + # 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.("#{source}: #{line}") end {:noreply, state} end - def handle_info({:received, "~txt "<>complete, _, chan}, state) do - case ExChain.SentenceGenerator.complete_sentence(state.markov, complete) do - {line, _} -> - ExIRC.Client.msg(state.client, :privmsg, chan, line) + + def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: args}}}, state) do + grep = Enum.join(args, " ") + result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + Enum.reduce(data, acc, fn({l, _}, acc) -> + [{trigger, l} | acc] + end) + end) + |> Enum.filter(fn({_, line}) -> String.contains?(String.downcase(line), String.downcase(grep)) end) + |> Enum.shuffle() + + if !Enum.empty?(result) do + {source, line} = Enum.random(result) + msg.replyfun.("#{source}: #{line}") + end + {:noreply, state} + 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({:received, "!"<>trigger, _, chan}, state) do - {trigger, opts} = clean_trigger(trigger) - line = get_random(state.triggers, trigger, opts) - if line do - ExIRC.Client.msg(state.client, :privmsg, chan, line) + 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 - def handle_info({:received, "+txt "<>trigger, sender=%ExIRC.SenderInfo{nick: nick}, chan}, state) do + # + # 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, chan, sender, trigger), + true <- can_write?(state, msg, trigger), :ok <- create_file(trigger) do - ExIRC.Client.msg(state.client, :privmsg, chan, "#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") + msg.replyfun.("#{trigger}.txt créé. Ajouter: `+#{trigger} …` ; Lire: `!#{trigger}`") {:noreply, %__MODULE__{state | triggers: load()}} else _ -> {:noreply, state} end end - def handle_info({:received, "+"<>trigger_and_content, sender=%ExIRC.SenderInfo{nick: nick}, chan}, state) do + # + # TXT: RANDOM + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: []}}}, state) do + {trigger, opts} = clean_trigger(trigger) + line = get_random(state.triggers, trigger, opts) + if line do + msg.replyfun.(line) + end + {:noreply, state} + end + + # + # TXT: ADD + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :plus, args: content}}}, state) do with \ - {trigger, _} <- clean_trigger(trigger_and_content), - true <- can_write?(state, chan, sender, trigger), - {:ok, idx} <- add(state.triggers, trigger_and_content) + true <- can_write?(state, msg, trigger), + {:ok, idx} <- add(state.triggers, msg.text) do - ExIRC.Client.msg(state.client, :privmsg, chan, "#{nick}: ajouté à #{trigger}. (#{idx})") + msg.replyfun.("#{msg.sender.nick}: ajouté à #{trigger}. (#{idx})") {:noreply, %__MODULE__{state | triggers: load()}} else - _ -> {:noreply, state} + _ -> + {:noreply, state} end end - def handle_info({:received, "-"<>trigger_and_id, sender=%ExIRC.SenderInfo{nick: nick}, chan}, state) do + # + # TXT: DELETE + # + + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :minus, args: [id]}}}, state) do with \ - [trigger, id] <- String.split(trigger_and_id, " ", parts: 2), - {trigger, _} = clean_trigger(trigger), - true <- can_write?(state, chan, sender, trigger), + 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) - ExIRC.Client.msg(state.client, :privmsg, chan, "#{trigger}.txt##{id} supprimée: #{text}") + msg.replyfun.("#{msg.sender.nick}: #{trigger}.txt##{id} supprimée: #{text}") dump(trigger, data) {:noreply, %__MODULE__{state | triggers: load()}} else - error -> - IO.inspect("error " <> inspect(error)) + _ -> {:noreply, state} end end def handle_info(:reload_markov, state=%__MODULE__{triggers: triggers, markov: markov}) do - all_data = triggers - |> Enum.map(fn({_, data}) -> - for {line, _idx} <- data, do: line - end) - |> List.flatten - - for l <- all_data, do: IO.puts(l) - - populate = ExChain.MarkovModel.populate_model(markov, all_data) - IO.puts "populated markov: #{inspect(populate)}" + state.markov_handler.reload(state.triggers, state.markov) {:noreply, state} end def handle_info(msg, state) do + IO.puts "txt unhandled #{inspect msg}" {:noreply, state} end @@ -334,29 +387,42 @@ defmodule LSG.IRC.TxtHandler do {trigger, opts} end - defp directory() do + def directory() do Application.get_env(:lsg, :data_path) <> "/irc.txt/" end - defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, channel, sender, trigger) do + defp can_write?(%{rw: rw?, locks: locks}, msg = %{channel: nil, sender: sender}, trigger) do admin? = IRC.admin?(sender) - operator? = IRC.UserTrack.operator?(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? + + can? = unlocked? || admin? if !can? do reason = if !rw?, do: "lecture seule", else: "fichier vérrouillé" - say(state, channel, "#{sender.nick}: permission refusée (#{reason})") + msg.replyfun.("#{sender.nick}: permission refusée (#{reason})") end can? end - defp say(%__MODULE__{client: client}, chan, message) do - ExIRC.Client.msg(client, :privmsg, chan, message) + defp can_write?(state = %__MODULE__{rw: rw?, locks: locks}, msg = %{channel: channel, sender: sender}, trigger) do + admin? = IRC.admin?(sender) + operator? = IRC.UserTrack.operator?(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 diff --git a/lib/lsg_irc/txt_plugin/markov.ex b/lib/lsg_irc/txt_plugin/markov.ex new file mode 100644 index 0000000..311138c --- /dev/null +++ b/lib/lsg_irc/txt_plugin/markov.ex @@ -0,0 +1,9 @@ +defmodule LSG.IRC.TxtPlugin.Markov do + + @type state :: any() + @callback start_link() :: {:ok, state()} + @callback reload(content :: Map.t, state()) :: any() + @callback sentence(state()) :: {:ok, String.t} | {:error, String.t} + @callback complete_sentence(state()) :: {:ok, String.t} | {:error, String.t} + +end diff --git a/lib/lsg_irc/txt_plugin/markov_native.ex b/lib/lsg_irc/txt_plugin/markov_native.ex new file mode 100644 index 0000000..524e860 --- /dev/null +++ b/lib/lsg_irc/txt_plugin/markov_native.ex @@ -0,0 +1,33 @@ +defmodule LSG.IRC.TxtPlugin.MarkovNative do + @behaviour LSG.IRC.TxtPlugin.Markov + + def start_link() do + ExChain.MarkovModel.start_link() + end + + def reload(data, markov) do + data = data + |> Enum.map(fn({_, data}) -> + for {line, _idx} <- data, do: line + end) + |> List.flatten + + ExChain.MarkovModel.populate_model(markov, data) + :ok + end + + def sentence(markov) do + case ExChain.SentenceGenerator.create_filtered_sentence(markov) do + {:ok, line, _, _} -> {:ok, line} + error -> error + end + end + + def complete_sentence(sentence, markov) do + case ExChain.SentenceGenerator.complete_sentence(markov, sentence) do + {line, _} -> {:ok, line} + error -> error + end + end + +end diff --git a/lib/lsg_irc/txt_plugin/markov_py_markovify.ex b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex new file mode 100644 index 0000000..a3838cd --- /dev/null +++ b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex @@ -0,0 +1,39 @@ +defmodule LSG.IRC.TxtPlugin.MarkovPyMarkovify do + + def start_link() do + {:ok, nil} + end + + def reload(_data, _markov) do + :ok + end + + def sentence(_) do + {:ok, run()} + end + + def complete_sentence(sentence, _) do + {:ok, run([sentence])} + end + + defp run(args \\ []) do + {binary, script} = script() + args = [script, Path.expand(LSG.IRC.TxtPlugin.directory()) | args] + IO.puts "Args #{inspect args}" + case MuonTrap.cmd(binary, args) do + {response, 0} -> response + {response, code} -> "error #{code}: #{response}" + end + end + + defp script() do + default_script = to_string(:code.priv_dir(:lsg)) <> "/irc/txt/markovify.py" + env = Application.get_env(:lsg, LSG.IRC.TxtPlugin, []) + |> Keyword.get(:py_markovify, []) + + {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} + end + + + +end diff --git a/lib/lsg_irc/wikipedia_plugin.ex b/lib/lsg_irc/wikipedia_plugin.ex index 4ee95b1..16e7c42 100644 --- a/lib/lsg_irc/wikipedia_plugin.ex +++ b/lib/lsg_irc/wikipedia_plugin.ex @@ -19,11 +19,11 @@ defmodule LSG.IRC.WikipediaPlugin do {:ok, nil} end - def handle_info({:irc, :trigger, "wp", message = %IRC.Message{trigger: %IRC.Trigger{args: []}}}, state) do + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do irc_random(message) {:noreply, state} end - def handle_info({:irc, :trigger, "wp", message = %IRC.Message{trigger: %IRC.Trigger{args: args}}}, state) do + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do irc_search(Enum.join(args, " "), message) {:noreply, state} end diff --git a/lib/lsg_irc/youtube_handler.ex b/lib/lsg_irc/youtube_handler.ex deleted file mode 100644 index 51584a2..0000000 --- a/lib/lsg_irc/youtube_handler.ex +++ /dev/null @@ -1,75 +0,0 @@ -defmodule LSG.IRC.YouTubeHandler do - require Logger - - @moduledoc """ - # youtube - - * **!yt `<recherche>`**, !youtube `<recherche>`: retourne le premier résultat de la `<recherche>` YouTube - """ - - defstruct client: nil - - def irc_doc, do: @moduledoc - - def start_link(client) do - GenServer.start_link(__MODULE__, [client]) - end - - def init([client]) do - ExIRC.Client.add_handler(client, self()) - {:ok, %__MODULE__{client: client}} - end - - def handle_info({:received, "!youtube " <> query, sender, chan}, state) do - irc_search(query, chan, state) - {:noreply, state} - end - - def handle_info({:received, "!yt " <> query, sender, chan}, state) do - irc_search(query, chan, state) - {:noreply, state} - end - - def handle_info(info, state) do - {:noreply, state} - end - - defp irc_search(query, chan, state) do - case search(query) do - {:ok, %{"items" => [item | _]}} -> - title = get_in(item, ["snippet", "title"]) - url = "https://youtube.com/watch?v=" <> get_in(item, ["id", "videoId"]) - msg = "#{title} — #{url}" - ExIRC.Client.msg(state.client, :privmsg, chan, msg) - {:error, error} -> - ExIRC.Client.msg(state.client, :privmsg, chan, "Erreur YouTube: "<>error) - _ -> - nil - end - end - - defp search(query) do - query = query - |> String.strip - key = Application.get_env(:lsg, __MODULE__)[:api_key] - params = %{ - "key" => key, - "maxResults" => 1, - "part" => "snippet", - "safeSearch" => "none", - "type" => "video", - "q" => query, - } - url = "https://www.googleapis.com/youtube/v3/search" - case HTTPoison.get(url, [], params: params) do - {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) - {:ok, %HTTPoison.Response{status_code: 400, body: body}} -> - Logger.error "YouTube HTTP 400: #{inspect body}" - {:error, "http 400"} - error -> - Logger.error "YouTube http error: #{inspect error}" - :error - end - end - -end diff --git a/lib/lsg_irc/youtube_plugin.ex b/lib/lsg_irc/youtube_plugin.ex new file mode 100644 index 0000000..e0781f0 --- /dev/null +++ b/lib/lsg_irc/youtube_plugin.ex @@ -0,0 +1,105 @@ +defmodule LSG.IRC.YouTubePlugin do + require Logger + + @moduledoc """ + # youtube + + * **!yt `<recherche>`**, !youtube `<recherche>`: retourne le premier résultat de la `<recherche>` YouTube + """ + + defstruct client: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:yt", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:youtube", []) + {:ok, %__MODULE__{}} + end + + def handle_info({:irc, :trigger, _, message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do + irc_search(Enum.join(args, " "), message) + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + defp irc_search(query, message) do + case search(query) do + {:ok, %{"items" => [item | _]}} -> + url = "https://youtube.com/watch?v=" <> item["id"] + snippet = item["snippet"] + duration = item["contentDetails"]["duration"] |> String.replace("PT", "") |> String.downcase + date = snippet["publishedAt"] + |> DateTime.from_iso8601() + |> elem(1) + |> Timex.format("{relative}", :relative) + |> elem(1) + + info_line = "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" + <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes," + <> " #{item["statistics"]["dislikeCount"]} dislikes" + message.replyfun.("#{snippet["title"]} — #{url}") + message.replyfun.(info_line) + {:error, error} -> + message.replyfun.("Erreur YouTube: "<>error) + _ -> + nil + end + end + + defp search(query) do + query = query + |> String.strip + key = Application.get_env(:lsg, :youtube)[:api_key] + params = %{ + "key" => key, + "maxResults" => 1, + "part" => "id", + "safeSearch" => "none", + "type" => "video", + "q" => query, + } + url = "https://www.googleapis.com/youtube/v3/search" + case HTTPoison.get(url, [], params: params) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + item = List.first(json["items"]) + if item do + video_id = item["id"]["videoId"] + params = %{ + "part" => "snippet,contentDetails,statistics", + "id" => video_id, + "key" => key + } + headers = [] + options = [params: params] + case HTTPoison.get("https://www.googleapis.com/youtube/v3/videos", [], options) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error "YouTube HTTP #{code}: #{inspect body}" + {:error, "http #{code}"} + error -> + Logger.error "YouTube http error: #{inspect error}" + :error + end + else + :error + end + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + Logger.error "YouTube HTTP #{code}: #{inspect body}" + {:error, "http #{code}"} + error -> + Logger.error "YouTube http error: #{inspect error}" + :error + end + end + +end diff --git a/lib/lsg_web/controllers/irc_controller.ex b/lib/lsg_web/controllers/irc_controller.ex index af7fff1..022807d 100644 --- a/lib/lsg_web/controllers/irc_controller.ex +++ b/lib/lsg_web/controllers/irc_controller.ex @@ -2,19 +2,35 @@ defmodule LSGWeb.IrcController do use LSGWeb, :controller def index(conn, _) do - doc = LSG.IRC.TxtHandler.irc_doc() commands = for mod <- (Application.get_env(:lsg, :irc)[:plugins] ++ Application.get_env(:lsg, :irc)[:handlers]) do - mod.irc_doc() + identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore + {identifier, mod.irc_doc()} end - |> Enum.reject(fn(i) -> i == nil end) + |> Enum.reject(fn({_, i}) -> i == nil end) render conn, "index.html", commands: commands end - def txt(conn, %{"name" => name}), do: do_txt(conn, name) + def txt(conn, %{"name" => name}) do + case String.split(name, ".", parts: 2) do + [name, "txt"] -> + data = data() + if Map.has_key?(data, name) do + lines = Enum.join(data[name], "\n") + text(conn, lines) + else + conn + |> put_status(404) + |> text("Not found") + end + _ -> + do_txt(conn, name) + end + end def txt(conn, _), do: do_txt(conn, nil) + defp do_txt(conn, nil) do - doc = LSG.IRC.TxtHandler.irc_doc() + doc = LSG.IRC.TxtPlugin.irc_doc() data = data() lines = Enum.reduce(data, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) render conn, "txts.html", data: data, doc: doc, files: Enum.count(data), lines: lines @@ -27,6 +43,7 @@ defmodule LSGWeb.IrcController do else conn |> put_status(404) + |> text("Not found") end end diff --git a/lib/lsg_web/router.ex b/lib/lsg_web/router.ex index 9373e85..c47e422 100644 --- a/lib/lsg_web/router.ex +++ b/lib/lsg_web/router.ex @@ -2,7 +2,7 @@ defmodule LSGWeb.Router do use LSGWeb, :router pipeline :browser do - plug :accepts, ["html"] + plug :accepts, ["html", "txt"] #plug :fetch_session #plug :fetch_flash #plug :protect_from_forgery diff --git a/lib/lsg_web/templates/irc/index.html.eex b/lib/lsg_web/templates/irc/index.html.eex index c42d71f..f127101 100644 --- a/lib/lsg_web/templates/irc/index.html.eex +++ b/lib/lsg_web/templates/irc/index.html.eex @@ -4,18 +4,17 @@ h1 small { } </style> -<h1> - <small><a href="https://115ans.net">115ans.net</a> › </small><br/> -irc 115ans</h1> +<h1><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></h1> -<p> +<!--<p> Serveur IRC: <a href="irc://irc.quakenet.org/lsg">irc.quakenet.org #lsg</a>. Matrix: <a href="https://matrix.random.sh/riot/#rooms/#lsg:random.sh">#lsg:random.sh</a>. <br /> <a href="https://115ans.net/irc/">Webchat</a>. <br /> <a href="/irc/stats/">Statistiques</a>. -</p> +</del> +</p>--> <style type="text/css"> @@ -27,12 +26,34 @@ Serveur IRC: <a href="irc://irc.quakenet.org/lsg">irc.quakenet.org #lsg</a>. } </style> +<p> + <% list = for {identifier, _} <- @commands do %> + <% name = String.replace(identifier, "_", " ") %> + <%= link(name, to: "##{identifier}") #raw("<a href='##{identifier}'>#{name}</a>") %> + <% end %> + <small><%= Enum.intersperse(list, " - ") %></small> +</p> + <div class="irchelps"> - <%= for help <- @commands do %> - <div class="help-entry"><%= help |> Earmark.as_html! |> raw() %></div> + <%= for {identifier, help} <- @commands do %> + <%= if help do %> + <div class="help-entry" id="<%= identifier %>"><%= help |> Earmark.as_html! |> raw() %></div> + <% end %> <% end %> </div> +<br/><br/><br/> + +<p> +<small> + <strong>Légende:</strong><br /> + entre <code>< ></code>: argument obligatoire,<br /> + entre <code>[ ]</code>: argument optionel; <code>[1 | ]</code>: argument optionel avec valeur par défaut. +</small> +</p> + +<br/><br/><br/> + <p> <small> source: <a href="https://git.yt/115ans/sys">git.yt/115ans/sys</a> diff --git a/lib/lsg_web/templates/irc/txt.html.eex b/lib/lsg_web/templates/irc/txt.html.eex index e060e5f..5b31320 100644 --- a/lib/lsg_web/templates/irc/txt.html.eex +++ b/lib/lsg_web/templates/irc/txt.html.eex @@ -8,7 +8,7 @@ ol li { </style> <h1> - <small><a href="https://115ans.net">115ans.net</a> › <a href="/irc">irc</a> › <a href="/irc/txt">txt</a> ›</small><br/> + <small><a href="/irc"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> › <a href="/irc/txt">txt</a> ›</small><br/> <%= @name %>.txt</h1> <ol> @@ -17,3 +17,6 @@ ol li { <% end %> </ol> +<p> + <a href="/irc/txt/<%= @name %>.txt">télécharger au format texte</a> +</p> diff --git a/lib/lsg_web/templates/irc/txts.html.eex b/lib/lsg_web/templates/irc/txts.html.eex index 5039c29..28d0220 100644 --- a/lib/lsg_web/templates/irc/txts.html.eex +++ b/lib/lsg_web/templates/irc/txts.html.eex @@ -9,10 +9,13 @@ h1 small { font-size: 14px; } </style> -<h1><small><a href="https://115ans.net">115ans.net</a> › <a href="/irc">irc</a> ›</small><br/> +<h1> + <small><a href="/irc"><%= Keyword.get(Application.get_env(:lsg, :irc), :name, "ircbot") %></a> › </small><br/> txt</h1> -<p><strong><%= @lines %></strong> lignes dans <strong><%= @files %></strong> fichiers.</p> +<p><strong><%= @lines %></strong> lignes dans <strong><%= @files %></strong> fichiers. +<a href="#help">Aide</a>. +</p> <ul> <%= for {txt, data} <- @data do %> @@ -20,5 +23,5 @@ h1 small { <% end %> </ul> -<div class="help-entry"><%= @doc |> Earmark.as_html! |> raw() %></div> +<div class="help-entry" id="help"><%= @doc |> Earmark.as_html! |> raw() %></div> diff --git a/lib/lsg_web/templates/page/index.html.eex b/lib/lsg_web/templates/page/index.html.eex index 98e407c..1b8519a 100644 --- a/lib/lsg_web/templates/page/index.html.eex +++ b/lib/lsg_web/templates/page/index.html.eex @@ -1 +1 @@ -<p>Vous n'avez rien de mieux à faire ? Non ? Allez sur <a href="https://115ans.net">115ans.net</a>, alors.</p> +<p>vOv</p> |