diff options
Diffstat (limited to 'lib/plugins')
44 files changed, 6124 insertions, 0 deletions
diff --git a/lib/plugins/README.md b/lib/plugins/README.md new file mode 100644 index 0000000..82adba8 --- /dev/null +++ b/lib/plugins/README.md @@ -0,0 +1 @@ +## Nola Plugins directory diff --git a/lib/plugins/account.ex b/lib/plugins/account.ex new file mode 100644 index 0000000..c11d077 --- /dev/null +++ b/lib/plugins/account.ex @@ -0,0 +1,188 @@ +defmodule Nola.Plugins.Account do + @moduledoc """ + # Account + + * **account** Get current account id and token + * **auth `<account-id>` `<token>`** Authenticate and link the current nickname to an account + * **auth** list authentications methods + * **whoami** list currently authenticated users + * **web** get a one-time login link to web + * **enable-telegram** Link a Telegram account + * **enable-sms** Link a SMS number + * **enable-untappd** Link a Untappd account + * **set-name** set account name + * **setusermeta puppet-nick `<nick>`** Set puppet IRC nickname + """ + + def irc_doc, do: @moduledoc + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) + {:ok, nil} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do + text = [ + "account: show current account and auth token", + "auth: show authentications methods", + "whoami: list authenticated users", + "set-name <name>: set account name", + "web: login to web", + "enable-sms | disable-sms: enable/change or disable sms", + "enable-telegram: link/change telegram", + "enable-untappd: link untappd account", + "getmeta: show meta datas", + "setusermeta: set user meta", + ] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do + spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] + predicates = :dets.select(IRC.Account.file("predicates"), spec) + text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do + users = for user <- IRC.UserTrack.find_by_account(m.account) do + chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) + |> Enum.join(" ") + "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" + end + m.replyfun.(users) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do + account = IRC.Account.lookup(m.sender) + text = ["Account Id: #{account.id}", + "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do + #account = IRC.Account.lookup(m.sender) + case String.split(m.text, " ") do + ["auth", id, token] -> + join_account(m, id, token) + _ -> + m.replyfun.("Invalid parameters") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do + IRC.Account.update_account_name(account, name) + m.replyfun.("Name changed: #{name}") + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do + if IRC.Account.get_meta(m.account, "sms-number") do + IRC.Account.delete_meta(m.account, "sms-number") + m.replfyun.("SMS disabled.") + else + m.replyfun.("SMS already disabled.") + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, nil) + m.replyfun.("-> " <> login_url) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.put_meta(m.account, "sms-validation-code", code) + IRC.Account.put_meta(m.account, "sms-validation-target", m.network) + number = Nola.IRC.Sms.my_number() + text = "To enable or change your number for SMS messaging, please send:" + <> " \"enable #{code}\" to #{number}" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do + code = String.downcase(EntropyString.small_id()) + IRC.Account.delete_meta(m.account, "telegram-id") + IRC.Account.put_meta(m.account, "telegram-validation-code", code) + IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) + text = "To enable or change your number for telegram messaging, please open #{Nola.Telegram.my_path()} and send:" + <> " \"/enable #{code}\"" + m.replyfun.(text) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do + auth_url = Untappd.auth_url() + login_url = Nola.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) + m.replyfun.(["To link your Untappd account, open this URL:", login_url]) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do + result = case String.split(m.text, " ") do + ["getmeta"] -> + for {k, v} <- IRC.Account.get_all_meta(m.account) do + case k do + "u:"<>key -> "(user) #{key}: #{v}" + key -> "#{key}: #{v}" + end + end + ["getmeta", key] -> + value = IRC.Account.get_meta(m.account, key) + text = if value do + "#{key}: #{value}" + else + "#{key} is not defined" + end + _ -> + "usage: getmeta [key]" + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %IRC.Message{text: "setusermeta"<>_}}, state) do + result = case String.split(m.text, " ") do + ["setusermeta", key, value] -> + IRC.Account.put_user_meta(m.account, key, value) + "ok" + _ -> + "usage: setusermeta <key> <value>" + end + m.replyfun.(result) + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp join_account(m, id, token) do + old_account = IRC.Account.lookup(m.sender) + new_account = IRC.Account.get(id) + if new_account && token == new_account.token do + case IRC.Account.merge_account(old_account.id, new_account.id) do + :ok -> + if old_account.id == new_account.id do + m.replyfun.("Already authenticated, but hello") + else + m.replyfun.("Accounts merged!") + end + _ -> m.replyfun.("Something failed :(") + end + else + m.replyfun.("Invalid token") + end + end + + end + diff --git a/lib/plugins/alcoolog.ex b/lib/plugins/alcoolog.ex new file mode 100644 index 0000000..7f44ca2 --- /dev/null +++ b/lib/plugins/alcoolog.ex @@ -0,0 +1,1229 @@ +defmodule Nola.Plugins.Alcoolog do + require Logger + + @moduledoc """ + # [alcoolog]({{context_path}}/alcoolog) + + * **!santai `<cl | (calc)>` `<degrés d'alcool> [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **!santai `<cl | (calc)>` `<beer name>`**: enregistre un nouveau verre de `cl` de la bière `beer name`, et checkin sur Untappd.com. + * **!moar `[cl]` : enregistre un verre équivalent au dernier !santai. + * **-santai**: annule la dernière entrée d'alcoolisme. + * **.alcoolisme**: état du channel en temps réel. + * **.alcoolisme `<semaine | Xj>`**: points par jour, sur X j. + * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. + * **!alcoolisme `[pseudo]` `<semaine | Xj>`**: affiche les points d'alcoolisme par jour sur X j. + * **+alcoolisme `<h|f>` `<poids en kg>` `[facteur de perte en mg/l (10, 15, 20, 25)]`**: Configure votre profil d'alcoolisme. + * **.sobre**: affiche quand la sobriété frappera sur le chan. + * **!sobre `[pseudo]`**: affiche quand la sobriété frappera pour `[pseudo]`. + * **!sobrepour `<date>`**: affiche tu pourras être sobre pour `<date>`, et si oui, combien de volumes d'alcool peuvent encore être consommés. + * **!alcoolog**: ([voir]({{context_path}}/alcoolog)) lien pour voir l'état/statistiques et historique de l'alcoolémie du channel. + * **!alcool `<cl>` `<degrés>`**: donne le nombre d'unités d'alcool dans `<cl>` à `<degrés>°`. + * **!soif**: c'est quand l'apéro ? + + 1 point = 1 volume d'alcool. + + Annotation: champ libre! + + --- + + ## `!txt`s + + * status utilisateur: `alcoolog.user_(sober|legal|legalhigh|high|toohigh|sick)(|_rising)` + * mauvaises boissons: `alcoolog.drink_(negative|zero|negative)` + * santo: `alcoolog.santo` + * santai: `alcoolog.santai` + * plus gros, moins gros: `alcoolog.(fatter|thinner)` + + """ + + def irc_doc, do: @moduledoc + + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + # tuple dets: {nick, date, volumes, current_alcohol_level, nom, commentaire} + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + # tuple meta dets: {nick, map} + # %{:weight => float, :sex => true(h),false(f)} + @pubsub ~w(account) + @pubsub_triggers ~w(santai moar again bis santo santeau alcoolog sobre sobrepour soif alcoolisme alcool) + @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} + + def data_state() do + dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} + end + + def init(_) do + triggers = for(t <- @pubsub_triggers, do: "trigger:"<>t) + for sub <- @pubsub ++ triggers do + {:ok, _} = Registry.register(IRC.PubSub, sub, plugin: __MODULE__) + end + dets_filename = (Nola.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + dets_meta_filename = (Nola.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) + traverse_fun = fn(obj, dets) -> + case obj do + object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment} -> + date = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} + :dets.delete_object(dets, object) + :dets.insert(dets, new) + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) + dets + + object = {nick, naive = %NaiveDateTime{}, volumes, active, cl, deg, name, comment, meta} -> + date = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + new = {nick, date, volumes, active, cl, deg, name, comment, Map.new()} + :dets.delete_object(dets, object) + :dets.insert(dets, new) + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, Map.new()}) + dets + + object = {nick, date, volumes, active, cl, deg, name, comment, meta} -> + :ets.insert(ets, {{nick, date}, volumes, active, cl, deg, name, comment, meta}) + dets + + _ -> + dets + end + end + :dets.foldl(traverse_fun, dets, dets) + :dets.sync(dets) + state = %{dets: dets, meta: meta, ets: ets} + {:ok, state} + end + + @eau ["santo", "santeau"] + def handle_info({:irc, :trigger, santeau, m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) when santeau in @eau do + Nola.Plugins.Txt.reply_random(m, "alcoolog.santo") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "soif", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do + now = DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + apero = format_duration_from_now(%DateTime{now | hour: 18, minute: 0, second: 0}, false) + day_of_week = Date.day_of_week(now) + {txt, apero?} = cond do + now.hour >= 0 && now.hour < 6 -> + {["apéro tardif ? Je dis OUI ! SANTAI !"], true} + now.hour >= 6 && now.hour < 12 -> + if day_of_week >= 6 do + {["de l'alcool pour le petit dej ? le week-end, pas de problème !"], true} + else + {["C'est quand même un peu tôt non ? Prochain apéro #{apero}"], false} + end + now.hour >= 12 && (now.hour < 14) -> + {["oui! c'est l'apéro de midi! (et apéro #{apero})", + "tu peux attendre #{apero} ou y aller, il est midi !" + ], true} + now.hour == 17 -> + {[ + "ÇA APPROCHE !!! Apéro #{apero}", + "BIENTÔT !!! Apéro #{apero}", + "achetez vite les teilles, apéro dans #{apero}!", + "préparez les teilles, apéro dans #{apero}!" + ], false} + now.hour >= 14 && now.hour < 18 -> + weekend = if day_of_week >= 6 do + " ... ou maintenant en fait, c'est le week-end!" + else + "" + end + {["tiens bon! apéro #{apero}#{weekend}", + "courage... apéro dans #{apero}#{weekend}", + "pas encore :'( apéro dans #{apero}#{weekend}" + ], false} + true -> + {[ + "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" + ], true} + end + + txt = txt + |> Enum.shuffle() + |> Enum.random() + + m.replyfun.(txt) + + stats = get_full_statistics(state, m.account.id) + if !apero? && stats.active > 0.1 do + m.replyfun.("(... ou continue en fait, je suis pas ta mère !)") + end + + {:noreply, state} + end + + def handle_info({:irc, :trigger, "sobrepour", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + args = Enum.join(args, " ") + {:ok, now} = DateTime.now("Europe/Paris", Tzdata.TimeZoneDatabase) + time = case args do + "demain " <> time -> + {h, m} = case String.split(time, [":", "h"]) do + [hour, ""] -> + IO.puts ("h #{inspect hour}") + {h, _} = Integer.parse(hour) + {h, 0} + [hour, min] when min != "" -> + {h, _} = Integer.parse(hour) + {m, _} = Integer.parse(min) + {h, m} + [hour] -> + IO.puts ("h #{inspect hour}") + {h, _} = Integer.parse(hour) + {h, 0} + _ -> {0, 0} + end + secs = ((60*60)*24) + day = DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) + %DateTime{day | hour: h, minute: m, second: 0} + "après demain " <> time -> + secs = 2*((60*60)*24) + DateTime.add(now, secs, :second, Tzdata.TimeZoneDatabase) + datetime -> + case Timex.Parse.DateTime.Parser.parse(datetime, "{}") do + {:ok, dt} -> dt + _ -> nil + end + end + + if time do + meta = get_user_meta(state, m.account.id) + stats = get_full_statistics(state, m.account.id) + + duration = round(DateTime.diff(time, now)/60.0) + + IO.puts "diff #{inspect duration} sober in #{inspect stats.sober_in}" + + if duration < stats.sober_in do + int = stats.sober_in - duration + m.replyfun.("désolé, aucune chance! tu seras sobre #{format_minute_duration(int)} après!") + else + remaining = duration - stats.sober_in + if remaining < 30 do + m.replyfun.("moins de 30 minutes de sobriété, c'est impossible de boire plus") + else + loss_per_minute = ((meta.loss_factor/100)/60) + remaining_gl = (remaining-30)*loss_per_minute + m.replyfun.("marge de boisson: #{inspect remaining} minutes, #{remaining_gl} g/l") + end + end + + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do + {:ok, token} = Nola.Token.new({:alcoolog, :index, m.sender.network, m.channel}) + url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel), token) + m.replyfun.("-> #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolog", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, state) do + url = NolaWeb.Router.Helpers.alcoolog_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + m.replyfun.("-> #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcool", m = %IRC.Message{trigger: %IRC.Trigger{args: args = [cl, deg], type: :bang}}}, state) do + {cl, _} = Util.float_paparse(cl) + {deg, _} = Util.float_paparse(deg) + points = Alcool.units(cl, deg) + meta = get_user_meta(state, m.account.id) + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + gl = (10*points)/(k*weight) + duration = round(gl/((meta.loss_factor/100)/60))+30 + sober_in_s = if duration > 0 do + duration = Timex.Duration.from_minutes(duration) + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + "" + end + + m.replyfun.("Il y a #{Float.round(points+0.0, 4)} unités d'alcool dans #{cl}cl à #{deg}° (#{Float.round(gl + 0.0, 4)} g/l, #{sober_in_s})") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: [cl, deg | comment], type: :bang}}}, state) do + santai(m, state, cl, deg, comment) + {:noreply, state} + end + + @moar [ + "{{message.sender.nick}}: la même donc ?", + "{{message.sender.nick}}: et voilà la petite sœur !" + ] + + def handle_info({:irc, :trigger, "bis", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_info({:irc, :trigger, "moar", m}, state) + end + def handle_info({:irc, :trigger, "again", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_info({:irc, :trigger, "moar", m}, state) + end + + def handle_info({:irc, :trigger, "moar", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + case get_statistics_for_nick(state, m.account.id) do + {_, obj = {_, _date, _points, _active, cl, deg, _name, comment, _meta}} -> + cl = case args do + [cls] -> + case Util.float_paparse(cls) do + {cl, _} -> cl + _ -> cl + end + _ -> cl + end + moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() + santai(m, state, cl, deg, comment, auto_set: true) + {_, obj = {_, date, points, _last_active, type, descr}} -> + case Regex.named_captures(~r/^(?<cl>\d+[.]\d+)cl\s+(?<deg>\d+[.]\d+)°$/, type) do + nil -> m.replyfun.("suce") + u -> + moar = @moar |> Enum.shuffle() |> Enum.random() |> Tmpl.render(m) |> m.replyfun.() + santai(m, state, u["cl"], u["deg"], descr, auto_set: true) + end + _ -> nil + end + {:noreply, state} + end + + defp santai(m, state, cl, deg, comment, options \\ []) do + comment = cond do + comment == [] -> nil + is_binary(comment) -> comment + comment == nil -> nil + true -> Enum.join(comment, " ") + end + + {cl, cl_extra} = case {Util.float_paparse(cl), cl} do + {{cl, extra}, _} -> {cl, extra} + {:error, "("<>_} -> + try do + {:ok, result} = Abacus.eval(cl) + {result, nil} + rescue + _ -> {nil, "cl: invalid calc expression"} + end + {:error, _} -> {nil, "cl: invalid value"} + end + + {deg, comment, auto_set, beer_id} = case Util.float_paparse(deg) do + {deg, _} -> {deg, comment, Keyword.get(options, :auto_set, false), nil} + :error -> + beername = if(comment, do: "#{deg} #{comment}", else: deg) + case Untappd.search_beer(beername, limit: 1) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [%{"beer" => beer, "brewery" => brewery} | _]}}}} -> + {Map.get(beer, "beer_abv"), "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")}", true, Map.get(beer, "bid")} + _ -> + {deg, "could not find beer", false, nil} + end + end + + cond do + cl == nil -> m.replyfun.(cl_extra) + deg == nil -> m.replyfun.(comment) + cl >= 500 || deg >= 100 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_toohuge") + cl == 0 || deg == 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_zero") + cl < 0 || deg < 0 -> Nola.Plugins.Txt.reply_random(m, "alcoolog.drink_negative") + true -> + points = Alcool.units(cl, deg) + now = m.at || DateTime.utc_now() + |> DateTime.to_unix(:millisecond) + user_meta = get_user_meta(state, m.account.id) + name = "#{cl}cl #{deg}°" + old_stats = get_full_statistics(state, m.account.id) + meta = %{} + meta = Map.put(meta, "timestamp", now) + meta = Map.put(meta, "weight", user_meta.weight) + meta = Map.put(meta, "sex", user_meta.sex) + :ok = :dets.insert(state.dets, {m.account.id, now, points, if(old_stats, do: old_stats.active, else: 0), cl, deg, name, comment, meta}) + true = :ets.insert(state.ets, {{m.account.id, now}, points, if(old_stats, do: old_stats.active, else: 0),cl, deg, name, comment, meta}) + #sante = @santai |> Enum.map(fn(s) -> String.trim(String.upcase(s)) end) |> Enum.shuffle() |> Enum.random() + sante = Nola.Plugins.Txt.random("alcoolog.santai") + k = if user_meta.sex, do: 0.7, else: 0.6 + weight = user_meta.weight + peak = Float.round((10*points||0.0)/(k*weight), 4) + stats = get_full_statistics(state, m.account.id) + sober_add = if old_stats && Map.get(old_stats || %{}, :sober_in) do + mins = round(stats.sober_in - old_stats.sober_in) + " [+#{mins}m]" + else + "" + end + nonow = DateTime.utc_now() + sober = nonow |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if nonow.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + + up = if stats.active_drinks > 1 do + " " <> Enum.join(for(_ <- 1..stats.active_drinks, do: "▲")) <> "" + else + "" + end + + since_str = if stats.since && stats.since_min > 180 do + "(depuis: #{stats.since_s}) " + else + "" + end + + msg = fn(nick, extra) -> + "#{sante} #{nick} #{extra}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" + <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) #{since_str}(sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" + <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)" + end + + meta = if beer_id do + Map.put(meta, "untappd:beer_id", beer_id) + else + meta + end + + if beer_id do + spawn(fn() -> + case Untappd.maybe_checkin(m.account, beer_id) do + {:ok, body} -> + badges = get_in(body, ["badges", "items"]) + if badges != [] do + badges_s = Enum.map(badges, fn(badge) -> Map.get(badge, "badge_name") end) + |> Enum.filter(fn(b) -> b end) + |> Enum.intersperse(", ") + |> Enum.join("") + badge = if(length(badges) > 1, do: "badges", else: "badge") + m.replyfun.("\\O/ Unlocked untappd #{badge}: #{badges_s}") + end + :ok + {:error, {:http_error, error}} when is_integer(error) -> m.replyfun.("Checkin to Untappd failed: #{to_string(error)}") + {:error, {:http_error, error}} -> m.replyfun.("Checkin to Untappd failed: #{inspect error}") + _ -> :error + end + end) + end + + local_extra = if auto_set do + if comment do + " #{comment} (#{cl}cl @ #{deg}°)" + else + "#{cl}cl @ #{deg}°" + end + else + "" + end + m.replyfun.(msg.(m.sender.nick, local_extra)) + notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + for {net, chan} <- notify do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + extra = " " <> present_type(name, comment) <> "" + IRC.Connection.broadcast_message(net, chan, msg.(nick, extra)) + end + + miss = cond do + points <= 0.6 -> :small + stats.active30m >= 2.9 && stats.active30m < 3 -> :miss3 + stats.active30m >= 1.9 && stats.active30m < 2 -> :miss2 + stats.active30m >= 0.9 && stats.active30m < 1 -> :miss1 + stats.active30m >= 0.45 && stats.active30m < 0.5 -> :miss05 + stats.active30m >= 0.20 && stats.active30m < 0.20 -> :miss025 + stats.active30m >= 3 && stats.active1h < 3.15 -> :small3 + stats.active30m >= 2 && stats.active1h < 2.15 -> :small2 + stats.active30m >= 1.5 && stats.active1h < 1.5 -> :small15 + stats.active30m >= 1 && stats.active1h < 1.15 -> :small1 + stats.active30m >= 0.5 && stats.active1h <= 0.51 -> :small05 + stats.active30m >= 0.25 && stats.active30m <= 0.255 -> :small025 + true -> nil + end + + if miss do + miss = Nola.Plugins.Txt.random("alcoolog.#{to_string(miss)}") + if miss do + for {net, chan} <- IRC.Membership.notify_channels(m.account) do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") + end + end + end + end + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: _, type: :bang}}}, state) do + m.replyfun.("!santai <cl> <degrés> [commentaire]") + {:noreply, state} + end + + def get_all_stats() do + IRC.Account.all_accounts() + |> Enum.map(fn(account) -> {account.id, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + def get_channel_statistics(account, network, nil) do + IRC.Membership.expanded_members_or_friends(account, network, nil) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + def get_channel_statistics(_, network, channel), do: get_channel_statistics(network, channel) + + def get_channel_statistics(network, channel) do + IRC.Membership.expanded_members(network, channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + end + + @spec since() :: %{IRC.Account.id() => DateTime.t()} + @doc "Returns the last time the user was at 0 g/l" + def since() do + :ets.foldr(fn({{acct, timestamp_or_date}, _vol, current, _cl, _deg, _name, _comment, _m}, acc) -> + if !Map.get(acc, acct) && current == 0 do + date = Util.to_date_time(timestamp_or_date) + Map.put(acc, acct, date) + else + acc + end + end, %{}, __MODULE__.ETS) + end + + def get_full_statistics(nick) do + get_full_statistics(data_state(), nick) + end + + defp get_full_statistics(state, nick) do + case get_statistics_for_nick(state, nick) do + {count, {_, last_at, last_points, last_active, last_cl, last_deg, last_type, last_descr, _meta}} -> + {active, active_drinks} = current_alcohol_level(state, nick) + {_, m30} = alcohol_level_rising(state, nick) + {rising, m15} = alcohol_level_rising(state, nick, 15) + {_, m5} = alcohol_level_rising(state, nick, 5) + {_, h1} = alcohol_level_rising(state, nick, 60) + + trend = if rising do + "▲" + else + "▼" + end + user_state = cond do + active <= 0.0 -> :sober + active <= 0.25 -> :low + active <= 0.50 -> :legal + active <= 1.0 -> :legalhigh + active <= 2.5 -> :high + active < 3 -> :toohigh + true -> :sick + end + + rising_file_key = if rising, do: "_rising", else: "" + txt_file = "alcoolog." <> "user_" <> to_string(user_state) <> rising_file_key + user_status = Nola.Plugins.Txt.random(txt_file) + + meta = get_user_meta(state, nick) + minutes_til_sober = h1/((meta.loss_factor/100)/60) + minutes_til_sober = cond do + active < 0 -> 0 + m15 < 0 -> 15 + m30 < 0 -> 30 + h1 < 0 -> 60 + minutes_til_sober > 0 -> + Float.round(minutes_til_sober+60) + true -> 0 + end + + duration = Timex.Duration.from_minutes(minutes_til_sober) + sober_in_s = if minutes_til_sober > 0 do + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + nil + end + + since = if active > 0 do + since() + |> Map.get(nick) + end + + since_diff = if since, do: Timex.diff(DateTime.utc_now(), since, :minutes) + since_duration = if since, do: Timex.Duration.from_minutes(since_diff) + since_s = if since, do: Timex.Format.Duration.Formatter.lformat(since_duration, "fr", :humanized) + + {total_volumes, total_gl} = user_stats(state, nick) + + + %{active: active, last_at: last_at, last_cl: last_cl, last_deg: last_deg, last_points: last_points, last_type: last_type, last_descr: last_descr, + trend_symbol: trend, + active5m: m5, active15m: m15, active30m: m30, active1h: h1, + rising: rising, + active_drinks: active_drinks, + user_status: user_status, + daily_gl: total_gl, daily_volumes: total_volumes, + sober_in: minutes_til_sober, sober_in_s: sober_in_s, + since: since, since_min: since_diff, since_s: since_s, + } + _ -> + nil + end + end + + def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do + nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && status.sober_in && status.sober_in > 0 end) + |> Enum.sort_by(fn({_, status}) -> status.sober_in end, &</2) + |> Enum.map(fn({nick, stats}) -> + now = DateTime.utc_now() + sober = now |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if now.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + "#{nick} sobre #{at} (dans #{stats.sober_in_s})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + |> (fn(line) -> + case line do + "" -> "tout le monde est sobre......." + line -> line + end + end).() + |> m.replyfun.() + {:noreply, state} + end + + def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + account = case args do + [nick] -> IRC.Account.find_always_by_nick(m.network, m.channel, nick) + [] -> m.account + end + + if account do + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + stats = get_full_statistics(state, account.id) + if stats && stats.sober_in > 0 do + now = DateTime.utc_now() + sober = now |> DateTime.add(round(stats.sober_in*60), :second) + |> Timex.Timezone.convert("Europe/Paris") + at = if now.day == sober.day do + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "aujourd'hui {h24}:{m}", "fr") + detail + else + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(sober, "{WDfull} {h24}:{m}", "fr") + detail + end + m.replyfun.("#{nick} sera sobre #{at} (dans #{stats.sober_in_s})!") + else + m.replyfun.("#{nick} est déjà sobre. aidez le !") + end + else + m.replyfun.("inconnu") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do + nicks = IRC.Membership.expanded_members_or_friends(m.account, m.network, m.channel) + |> Enum.map(fn({account, _, nick}) -> {nick, get_full_statistics(state, account.id)} end) + |> Enum.filter(fn({_nick, status}) -> status && (status.active > 0 || status.active30m > 0) end) + |> Enum.sort_by(fn({_, status}) -> status.active end, &>/2) + |> Enum.map(fn({nick, status}) -> + trend_symbol = if status.active_drinks > 1 do + Enum.join(for(_ <- 1..status.active_drinks, do: status.trend_symbol)) + else + status.trend_symbol + end + since_str = if status.since_min > 180 do + "depuis: #{status.since_s} | " + else + "" + end + "#{nick} #{status.user_status} #{trend_symbol} #{Float.round(status.active, 4)} g/l [#{since_str}sobre dans: #{status.sober_in_s}]" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + msg = if nicks == "" do + "wtf?!?! personne n'a bu!" + else + nicks + end + + m.replyfun.(msg) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [time], type: :dot}}}, state) do + time = case time do + "semaine" -> 7 + string -> + case Integer.parse(string) do + {time, "j"} -> time + {time, "J"} -> time + _ -> nil + end + end + + if time do + aday = time*((24 * 60)*60) + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + over_time_stats(before, time, m, state) + else + m.replyfun.(".alcooolisme semaine|Xj") + end + {:noreply, state} + end + + def user_over_time(account, count) do + user_over_time(data_state(), account, count) + end + + def user_over_time(state, account, count) do + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + before = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_unix(:millisecond) + #[ +# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, +# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} + #] + match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} + ] + :ets.select(state.ets, match) + |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> + date = DateTime.from_unix!(ts, :millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + + date = if date.hour <= 8 do + DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) + else + date + end + |> DateTime.to_date() + + Map.put(acc, date, Map.get(acc, date, 0) + vol) + end) + end + + def user_over_time_gl(account, count) do + state = data_state() + meta = get_user_meta(state, account.id) + delay = count*((24 * 60)*60) + now = DateTime.utc_now() + before = DateTime.utc_now() + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) + |> DateTime.to_unix(:millisecond) + #[ +# {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, +# [{:andalso, {:==, :"$1", :"$1"}, {:<, :"$2", {:const, 3000}}}], [:lol]} + #] + match = [{{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [{:andalso, {:>, :"$2", {:const, before}}, {:==, :"$1", {:const, account.id}}}], [:"$_"]} + ] + :ets.select(state.ets, match) + |> Enum.reduce(Map.new, fn({{_, ts}, vol, _, _, _, _, _, _}, acc) -> + date = DateTime.from_unix!(ts, :millisecond) + |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) + + date = if date.hour <= 8 do + DateTime.add(date, -(60*(60*(date.hour+1))), :second, Tzdata.TimeZoneDatabase) + else + date + end + |> DateTime.to_date() + weight = meta.weight + k = if meta.sex, do: 0.7, else: 0.6 + gl = (10*vol)/(k*weight) + + Map.put(acc, date, Map.get(acc, date, 0) + gl) + end) + end + + + + defp over_time_stats(before, j, m, state) do + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _, _, _, _}) when date > before -> obj end) + match = [{{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, + [{:>, :"$1", {:const, before}}], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + members = IRC.Membership.members_or_friends(m.account, m.network, m.channel) + drinks = :ets.select(state.ets, match) + |> Enum.filter(fn({{account, _}, _, _, _, _, _, _, _}) -> Enum.member?(members, account) end) + |> Enum.sort_by(fn({{_, ts}, _, _, _, _, _, _, _}) -> ts end, &>/2) + + top = Enum.reduce(drinks, %{}, fn({{nick, _}, vol, _, _, _, _, _, _}, acc) -> + all = Map.get(acc, nick, 0) + Map.put(acc, nick, all + vol) + end) + |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) + |> Enum.map(fn({nick, count}) -> + account = IRC.Account.get(nick) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{Float.round(count, 4)}" + end) + |> Enum.intersperse(", ") + + m.replyfun.("sur #{j} jours: #{top}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :plus}}}, state) do + meta = get_user_meta(state, m.account.id) + hf = if meta.sex, do: "h", else: "f" + m.replyfun.("+alcoolisme sexe: #{hf} poids: #{meta.weight} facteur de perte: #{meta.loss_factor}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [h, weight | rest], type: :plus}}}, state) do + h = case h do + "h" -> true + "f" -> false + _ -> nil + end + + weight = case Util.float_paparse(weight) do + {weight, _} -> weight + _ -> nil + end + + {factor} = case rest do + [factor] -> + case Util.float_paparse(factor) do + {float, _} -> {float} + _ -> {@default_user_meta.loss_factor} + end + _ -> {@default_user_meta.loss_factor} + end + + if h == nil || weight == nil do + m.replyfun.("paramètres invalides") + else + old_meta = get_user_meta(state, m.account.id) + meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) + put_user_meta(state, m.account.id, meta) + cond do + old_meta.weight < meta.weight -> + Nola.Plugins.Txt.reply_random(m, "alcoolog.fatter") + old_meta.weight == meta.weight -> + m.replyfun.("aucun changement!") + true -> + Nola.Plugins.Txt.reply_random(m, "alcoolog.thinner") + end + end + + {:noreply, state} + end + + def handle_info({:irc, :trigger, "santai", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :minus}}}, state) do + case get_statistics_for_nick(state, m.account.id) do + {_, obj = {_, date, points, _last_active, _cl, _deg, type, descr, _meta}} -> + :dets.delete_object(state.dets, obj) + :ets.delete(state.ets, {m.account.id, date}) + m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") + Nola.Plugins.Txt.reply_random(m, "alcoolog.delete") + notify = IRC.Membership.notify_channels(m.account) -- [{m.network,m.channel}] + for {net, chan} <- notify do + user = IRC.UserTrack.find_by_account(net, m.account) + nick = if(user, do: user.nick, else: m.account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick} -santai #{points} #{type} #{descr}") + end + {:noreply, state} + _ -> + {:noreply, state} + end + end + + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + {account, duration} = case args do + [nick | rest] -> {IRC.Account.find_always_by_nick(m.network, m.channel, nick), rest} + [] -> {m.account, []} + end + if account do + duration = case duration do + ["semaine"] -> 7 + [j] -> + case Integer.parse(j) do + {j, "j"} -> j + _ -> nil + end + _ -> nil + end + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + if duration do + if duration > 90 do + m.replyfun.("trop gros, ça rentrera pas") + else + # duration stats + stats = user_over_time(state, account, duration) + |> Enum.sort_by(fn({k,_v}) -> k end, {:asc, Date}) + |> Enum.map(fn({date, count}) -> + "#{date.day}: #{Float.round(count, 2)}" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + + if stats == "" do + m.replyfun.("alcoolisme a zéro sur #{duration}j :/") + else + m.replyfun.("alcoolisme de #{nick}, #{duration} derniers jours: #{stats}") + end + end + else + if stats = get_full_statistics(state, account.id) do + trend_symbol = if stats.active_drinks > 1 do + Enum.join(for(_ <- 1..stats.active_drinks, do: stats.trend_symbol)) + else + stats.trend_symbol + end + # TODO: Lookup nick for account_id + msg = "#{nick} #{stats.user_status} " + <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{Float.round(stats.active, 4)}g/l ", else: "") + <> (if stats.active30m > 0 || stats.active1h > 0, do: "(15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) ", else: "") + <> (if stats.sober_in > 0, do: "— Sobre dans #{stats.sober_in_s} ", else: "") + <> (if stats.since && stats.since_min > 180, do: "— Paitai depuis #{stats.since_s} ", else: "") + <> "— Dernier verre: #{present_type(stats.last_type, stats.last_descr)} [#{Float.round(stats.last_points+0.0, 4)}] " + <> "#{format_duration_from_now(stats.last_at)} " + <> (if stats.daily_volumes > 0, do: "— Aujourd'hui: #{stats.daily_volumes} #{stats.daily_gl}g/l", else: "") + + m.replyfun.(msg) + else + m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") + end + end + else + m.replyfun.("je ne connais pas cet utilisateur") + end + {:noreply, state} + end + + + # Account merge + def handle_info({:account_change, old_id, new_id}, state) do + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("alcolog/account_change:: merging #{old_id} -> #{new_id}") + rename_object_owner(table, state.ets, obj, old_id, new_id) + end) + case :dets.lookup(state.meta, {:meta, old_id}) do + [{_, meta}] -> + :dets.delete(state.meta, {:meta, old_id}) + :dets.insert(state.meta, {{:meta, new_id}, meta}) + _ -> + :ok + end + {:noreply, state} + end + + def terminate(_, state) do + for dets <- [state.dets, state.meta] do + :dets.sync(dets) + :dets.close(dets) + end + end + + defp rename_object_owner(table, ets, object = {old_id, date, volume, current, cl, deg, name, comment, meta}, old_id, new_id) do + :dets.delete_object(table, object) + :ets.delete(ets, {old_id, date}) + :dets.insert(table, {new_id, date, volume, current, cl, deg, name, comment, meta}) + :ets.insert(ets, {{new_id, date}, volume, current, cl, deg, name, comment, meta}) + end + + # Account: move from nick to account id + def handle_info({:accounts, accounts}, state) do + #for x={:account, _, _, _, _} <- accounts, do: handle_info(x, state) + #{:noreply, state} + mapping = Enum.reduce(accounts, Map.new, fn({:account, _net, _chan, nick, account_id}, acc) -> + Map.put(acc, String.downcase(nick), account_id) + end) + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [], [:"$_"]}] + Logger.debug("accounts:: mappings #{inspect mapping}") + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj = {nick, _date, _vol, _cur, _cl, _deg, _name, _comment, _meta}) -> + #Logger.debug("accounts:: item #{inspect(obj)}") + if new_id = Map.get(mapping, nick) do + Logger.debug("alcolog/accounts:: merging #{nick} -> #{new_id}") + rename_object_owner(table, state.ets, obj, nick, new_id) + end + end) + {:noreply, state} + end + + def handle_info({:account, _net, _chan, nick, account_id}, state) do + nick = String.downcase(nick) + spec = [{{:"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("alcoolog/account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, state.ets, obj, nick, account_id) + end) + case :dets.lookup(state.meta, {:meta, nick}) do + [{_, meta}] -> + :dets.delete(state.meta, {:meta, nick}) + :dets.insert(state.meta, {{:meta, account_id}, meta}) + _ -> + :ok + end + {:noreply, state} + end + + def handle_info(t, state) do + Logger.debug("#{__MODULE__}: unhandled info #{inspect t}") + {:noreply, state} + end + + def nick_history(account) do + spec = [ + {{{:"$1", :_}, :_, :_, :_, :_, :_, :_, :_}, + [{:==, :"$1", {:const, account.id}}], + [:"$_"]} + ] + :ets.select(data_state().ets, spec) + end + + defp get_statistics_for_nick(state, account_id) do + qvc = :dets.lookup(state.dets, account_id) + |> Enum.sort_by(fn({_, ts, _, _, _, _, _, _, _}) -> ts end, &</2) + count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _active, _cl, _deg, _type, _descr, _meta}, acc) -> acc + (points||0) 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 is_integer(int) and int > 0 do + "+#{Integer.to_string(int)}" + end + def format_points(int) when is_integer(int) and int < 0 do + Integer.to_string(int) + end + def format_points(int) when is_float(int) and int > 0 do + "+#{Float.to_string(Float.round(int,4))}" + end + def format_points(int) when is_float(int) and int < 0 do + Float.to_string(Float.round(int,4)) + end + def format_points(0), do: "0" + def format_points(0.0), do: "0" + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix!(:millisecond) + |> 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 + + defp put_user_meta(state, account_id, meta) do + :dets.insert(state.meta, {{:meta, account_id}, meta}) + :ok + end + + defp get_user_meta(%{meta: meta}, account_id) do + case :dets.lookup(meta, {:meta, account_id}) do + [{{:meta, _}, meta}] -> + Map.merge(@default_user_meta, meta) + _ -> + @default_user_meta + end + end + # Calcul g/l actuel: + # 1. load user meta + # 2. foldr ets + # for each object + # get_current_alcohol + # ((object g/l) - 0,15/l/60)* minutes_since_drink + # if minutes_since_drink < 10, reduce g/l (?!) + # acc + current_alcohol + # stop folding when ? + # + + def user_stats(account) do + user_stats(data_state(), account.id) + end + + defp user_stats(state = %{ets: ets}, account_id) do + meta = get_user_meta(state, account_id) + aday = (10 * 60)*60 + now = DateTime.utc_now() + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$2", {:const, before}}, + {:"=:=", {:const, account_id}, :"$1"} + ], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = :ets.select(ets, match) + # {date, single_peak} + total_volume = Enum.reduce(drinks, 0.0, fn({{_, date}, volume, _, _, _, _, _, _}, acc) -> + acc + volume + end) + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + gl = (10*total_volume)/(k*weight) + {Float.round(total_volume + 0.0, 4), Float.round(gl + 0.0, 4)} + end + + defp alcohol_level_rising(state, account_id, minutes \\ 30) do + {now, _} = current_alcohol_level(state, account_id) + soon_date = DateTime.utc_now + |> DateTime.add(minutes*60, :second) + {soon, _} = current_alcohol_level(state, account_id, soon_date) + soon = cond do + soon < 0 -> 0.0 + true -> soon + end + #IO.puts "soon #{soon_date} - #{inspect soon} #{inspect now}" + {soon > now, Float.round(soon+0.0, 4)} + end + + defp current_alcohol_level(state = %{ets: ets}, account_id, now \\ nil) do + meta = get_user_meta(state, account_id) + aday = ((24*7) * 60)*60 + now = if now do + now + else + DateTime.utc_now() + end + before = now + |> DateTime.add(-aday, :second) + |> DateTime.to_unix(:millisecond) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:"$1", :"$2"}, :_, :_, :_, :_, :_, :_, :_}, + [ + {:>, :"$2", {:const, before}}, + {:"=:=", {:const, account_id}, :"$1"} + ], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = :ets.select(ets, match) + |> Enum.sort_by(fn({{_, date}, _, _, _, _, _, _, _}) -> date end, &</2) + # {date, single_peak} + {all, last_drink_at, gl, active_drinks} = Enum.reduce(drinks, {0.0, nil, [], 0}, fn({{_, date}, volume, _, _, _, _, _, _}, {all, last_at, acc, active_drinks}) -> + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + peak = (10*volume)/(k*weight) + date = case date do + ts when is_integer(ts) -> DateTime.from_unix!(ts, :millisecond) + date = %NaiveDateTime{} -> DateTime.from_naive!(date, "Etc/UTC") + date = %DateTime{} -> date + end + last_at = last_at || date + mins_since = round(DateTime.diff(now, date)/60.0) + #IO.puts "Drink: #{inspect({date, volume})} - mins since: #{inspect mins_since} - last drink at #{inspect last_at}" + # Apply loss since `last_at` on `all` + # + all = if last_at do + mins_since_last = round(DateTime.diff(date, last_at)/60.0) + loss = ((meta.loss_factor/100)/60)*(mins_since_last) + #IO.puts "Applying last drink loss: from #{all}, loss of #{inspect loss} (mins since #{inspect mins_since_first})" + cond do + (all-loss) > 0 -> all - loss + true -> 0.0 + end + else + all + end + #IO.puts "Applying last drink current before drink: #{inspect all}" + if mins_since < 30 do + per_min = (peak)/30.0 + current = (per_min*mins_since) + #IO.puts "Applying current drink 30m: from #{peak}, loss of #{inspect per_min}/min (mins since #{inspect mins_since})" + {all + current, date, [{date, current} | acc], active_drinks + 1} + else + {all + peak, date, [{date, peak} | acc], active_drinks} + end + end) + #IO.puts "last drink #{inspect last_drink_at}" + mins_since_last = if last_drink_at do + round(DateTime.diff(now, last_drink_at)/60.0) + else + 0 + end + # Si on a déjà bu y'a déjà moins 15 minutes (big up le binge drinking), on applique plus de perte + level = if mins_since_last > 15 do + loss = ((meta.loss_factor/100)/60)*(mins_since_last) + Float.round(all - loss, 4) + else + all + end + #IO.puts "\n LEVEL #{inspect level}\n\n\n\n" + cond do + level < 0 -> {0.0, 0} + true -> {level, active_drinks} + end + end + + defp format_duration_from_now(date, with_detail \\ true) do + date = if is_integer(date) do + date = DateTime.from_unix!(date, :millisecond) + |> Timex.Timezone.convert("Europe/Paris") + else + Util.to_naive_date_time(date) + end + now = DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + {:ok, detail} = Timex.Format.DateTime.Formatters.Default.lformat(date, "({h24}:{m})", "fr") + + mins_since = round(DateTime.diff(now, date)/60.0) + if ago = format_minute_duration(mins_since) do + word = if mins_since > 0 do + "il y a " + else + "dans " + end + word <> ago <> if(with_detail, do: " #{detail}", else: "") + else + "maintenant #{detail}" + end + end + + defp format_minute_duration(minutes) do + sober_in_s = if (minutes != 0) do + duration = Timex.Duration.from_minutes(minutes) + Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + else + nil + end + end + +end diff --git a/lib/plugins/alcoolog_announcer.ex b/lib/plugins/alcoolog_announcer.ex new file mode 100644 index 0000000..674f19b --- /dev/null +++ b/lib/plugins/alcoolog_announcer.ex @@ -0,0 +1,269 @@ +defmodule Nola.Plugins.AlcoologAnnouncer do + require Logger + + @moduledoc """ + Annonce changements d'alcoolog + """ + + @channel "#dmz" + + @seconds 30 + + @apero [ + "C'EST L'HEURE DE L'APÉRRROOOOOOOO !!", + "SAAAAANNNNNNNTTTTTTTTAAAAAAAAIIIIIIIIIIIIIIIIIIIIII", + "APÉRO ? APÉRO !", + {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, + "/!\\ ALERTE APÉRO /!\\", + "CED !!! VASE DE ROUGE !", + "DIDI UN PETIT RICARD™??!", + "ALLEZ GUIGUI UNE PETITE BIERE ?", + {:timed, ["/!\\ à vos tires bouchons…", "… débouchez …", "… BUVEZ! SANTAIII!"]}, + "APPPPAIIIRRREAAUUUUUUUUUUU" + ] + + def irc_doc, do: nil + + def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def log(account) do + dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + from = ~U[2020-08-23 19:41:40.524154Z] + to = ~U[2020-08-24 19:41:40.524154Z] + select = [ + {{:"$1", :"$2", :_}, + [ + {:andalso, + {:andalso, {:==, :"$1", {:const, account.id}}, + {:>, :"$2", {:const, DateTime.to_unix(from)}}}, + {:<, :"$2", {:const, DateTime.to_unix(to)}}} + ], [:"$_"]} + ] + res = :dets.select(dets, select) + :dets.close(dets) + res + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "account", []) + stats = get_stats() + Process.send_after(self(), :stats, :timer.seconds(30)) + dets_filename = (Nola.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + ets = nil # :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + {:ok, {stats, now(), dets, ets}}#, {:continue, :traverse}} + end + + def handle_continue(:traverse, state = {_, _, dets, ets}) do + traverse_fun = fn(obj, dets) -> + case obj do + {nick, %DateTime{} = dt, active} -> + :dets.delete_object(dets, obj) + :dets.insert(dets, {nick, DateTime.to_unix(dt), active}) + IO.puts("ok #{inspect obj}") + dets + {nick, ts, value} -> + :ets.insert(ets, { {nick, ts}, value }) + dets + end + end + :dets.foldl(traverse_fun, dets, dets) + :dets.sync(dets) + IO.puts("alcoolog announcer fixed") + {:noreply, state} + end + + def alcohol_reached(old, new, level) do + (old.active < level && new.active >= level) && (new.active5m >= level) + end + + def alcohol_below(old, new, level) do + (old.active > level && new.active <= level) && (new.active5m <= level) + end + + + def handle_info(:stats, {old_stats, old_now, dets, ets}) do + stats = get_stats() + now = now() + + if old_now.hour < 18 && now.hour == 18 do + apero = Enum.shuffle(@apero) + |> Enum.random() + + case apero do + {:timed, list} -> + spawn(fn() -> + for line <- list do + IRC.Connection.broadcast_message("evolu.net", "#dmz", line) + :timer.sleep(:timer.seconds(5)) + end + end) + string -> + IRC.Connection.broadcast_message("evolu.net", "#dmz", string) + end + + end + + #IO.puts "newstats #{inspect stats}" + events = for {acct, old} <- old_stats do + new = Map.get(stats, acct, nil) + #IO.puts "#{acct}: #{inspect(old)} -> #{inspect(new)}" + + now = DateTime.to_unix(DateTime.utc_now()) + if new && new[:active] do + :dets.insert(dets, {acct, now, new[:active]}) + :ets.insert(ets, {{acct, now}, new[:active]}) + else + :dets.insert(dets, {acct, now, 0.0}) + :ets.insert(ets, {{acct, now}, new[:active]}) + end + + event = cond do + old == nil -> nil + (old.active > 0) && (new == nil) -> :sober + new == nil -> nil + alcohol_reached(old, new, 0.5) -> :stopconduire + alcohol_reached(old, new, 1.0) -> :g1 + alcohol_reached(old, new, 2.0) -> :g2 + alcohol_reached(old, new, 3.0) -> :g3 + alcohol_reached(old, new, 4.0) -> :g4 + alcohol_reached(old, new, 5.0) -> :g5 + alcohol_reached(old, new, 6.0) -> :g6 + alcohol_reached(old, new, 7.0) -> :g7 + alcohol_reached(old, new, 10.0) -> :g10 + alcohol_reached(old, new, 13.74) -> :record + alcohol_below(old, new, 0.5) -> :conduire + alcohol_below(old, new, 1.0) -> :fini1g + alcohol_below(old, new, 2.0) -> :fini2g + alcohol_below(old, new, 3.0) -> :fini3g + alcohol_below(old, new, 4.0) -> :fini4g + (old.rising) && (!new.rising) -> :lowering + true -> nil + end + {acct, event} + end + + for {acct, event} <- events do + message = case event do + :g1 -> [ + "[vigicuite jaune] LE GRAMME! LE GRAMME O/", + "début de vigicuite jaune ! LE GRAMME ! \\O/", + "waiiiiiiii le grammmeee", + "bourraiiiiiiiiiiide 1 grammeeeeeeeeeee", + ] + :g2 -> [ + "[vigicuite orange] \\o_YAY 2 GRAMMES ! _o/", + "PAITAIIIIIIIIII DEUX GRAMMEESSSSSSSSSSSSSSSSS", + "bourrrrrraiiiiiiiiiiiiiiiide 2 grammeeeeeeeeeees", + ] + :g3 -> [ + "et un ! et deux ! et TROIS GRAMMEEESSSSSSS", + "[vigicuite rouge] _o/ BOURRAIIDDDEEEE 3 GRAMMESSSSSSSSS \\o/ \\o/" + ] + :g4 -> [ + "[vigicuite écarlate] et un, et deux, et trois, ET QUATRES GRAMMEESSSSSSSSSSSSSSSSSSSssssss" + ] + :g5 -> "[vigicuite écarlate+] PUTAIN 5 GRAMMES !" + :g6 -> "[vigicuite écarlate++] 6 grammes ? Vous pouvez joindre Alcool info service au 0 980 980 930" + :g7 -> "[vigicuite c'est la merde] 7 grammes. Le SAMU, c'est le 15." + :g10 -> "BORDLE 10 GRAMMES" + :record -> "RECORD DU MONDE BATTU ! >13.74g/l !!" + :fini1g -> [ + "fin d'alerte vigicuite jaune, passage en vert (<1g/l)", + "/!\\ alerte moins de 1g/l /!\\" + ] + :fini2g -> [ + "t'as moins de 2 g/l, faut se reprendre là [vigicuite jaune]" + ] + :fini3g -> [ + "fin d'alerte vigicuite rouge, passage en orange (<3g/l)" + ] + :fini4g -> [ + "fin d'alerte vigicuite écarlate, passage en rouge (<4g/l)" + ] + :lowering -> [ + "attention ça baisse!", + "tu vas quand même pas en rester là ?", + "IL FAUT CONTINUER À BOIRE !", + "t'abandonnes déjà ?", + "!santai ?", + "faut pas en rester là", + "il faut se resservir", + "coucou faut reboire", + "encore un petit verre ?", + "abwaaaaaaaaaaaaarrrrrrrrrrrrrr", + "taux d'alcoolémie en chute ! agissez avant qu'il soit trop tard!", + "ÇA BAISSE !!" + ] + :stopconduire -> [ + "0.5g! bientot le gramme?", + "tu peux plus prendre la route... mais... tu peux prendre la route du gramme! !santai !", + "fini la conduite!", + "0.5! continues faut pas en rester là!", + "beau début, continues !", + "ça monte! 0.5g/l!" + ] + :conduire -> [ + "tu peux conduire, ou recommencer à boire! niveau critique!", + "!santai ?", + "tu peux reprendre la route, ou reprendre la route du gramme..", + "attention, niveau critique!", + "il faut boire !!", + "trop de sang dans ton alcool, c'est mauvais pour la santé", + "faut pas en rester là !", + ] + :sober -> [ + "sobre…", + "/!\\ alerte sobriété /!\\", + "... sobre?!?!", + "sobre :(", + "attention, t'es sobre :/", + "danger, alcoolémie à 0.0 !", + "sobre! c'était bien on recommence quand ?", + "sobre ? Faut recommencer...", + "T'es sobre. Ne te laisses pas abattre- ton caviste peut aider.", + "Vous êtes sobre ? Ceci n'est pas une fatalité - resservez vous vite !" + ] + _ -> nil + end + message = case message do + m when is_binary(m) -> m + m when is_list(m) -> m |> Enum.shuffle() |> Enum.random() + nil -> nil + end + if message do + #IO.puts("#{acct}: #{message}") + account = IRC.Account.get(acct) + for {net, chan} <- IRC.Membership.notify_channels(account) do + user = IRC.UserTrack.find_by_account(net, account) + nick = if(user, do: user.nick, else: account.name) + IRC.Connection.broadcast_message(net, chan, "#{nick}: #{message}") + end + end + end + + timer() + + #IO.puts "tick stats ok" + {:noreply, {stats,now,dets,ets}} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp now() do + DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + end + + defp get_stats() do + Enum.into(Nola.Plugins.Alcoolog.get_all_stats(), %{}) + end + + defp timer() do + Process.send_after(self(), :stats, :timer.seconds(@seconds)) + end + +end diff --git a/lib/plugins/base.ex b/lib/plugins/base.ex new file mode 100644 index 0000000..b7b5e16 --- /dev/null +++ b/lib/plugins/base.ex @@ -0,0 +1,132 @@ +defmodule Nola.Plugins.Base do + + def irc_doc, do: nil + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:version", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:help", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:liquidrender", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugin", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:plugins", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "plugins", msg = %{trigger: %{type: :bang, args: []}}}, _) do + enabled_string = Nola.Plugins.enabled() + |> Enum.map(fn(mod) -> + mod + |> Macro.underscore() + |> String.split("/", parts: :infinity) + |> List.last() + |> Enum.sort() + end) + |> Enum.join(", ") + msg.replyfun.("Enabled plugins: #{enabled_string}") + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :query, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module) + do + m.replyfun.("loaded, active: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> + msg = case Nola.Plugins.get(module) do + :disabled -> "disabled" + {_, false, _} -> "disabled" + _ -> "not active" + end + m.replyfun.(msg) + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :plus, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) + with true <- Code.ensure_loaded?(module), + Nola.Plugins.switch(module, true), + {:ok, pid} <- Nola.Plugins.start(module) + do + m.replyfun.("started: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + :ignore -> m.replyfun.("disabled or throttled") + {:error, _} -> m.replyfun.("start error") + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :tilde, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module), + :ok <- GenServer.stop(pid), + {:ok, pid} <- Nola.Plugins.start(module) + do + m.replyfun.("restarted: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> m.replyfun.("not active") + end + {:noreply, nil} + end + + + def handle_info({:irc, :trigger, "plugin", %{trigger: %{type: :minus, args: [plugin]}} = m}, _) do + module = Module.concat([Nola.Plugins, Macro.camelize(plugin)]) + with true <- Code.ensure_loaded?(module), + pid when is_pid(pid) <- GenServer.whereis(module), + :ok <- GenServer.stop(pid) + do + IRC.Plugin.switch(module, false) + m.replyfun.("stopped: #{inspect(pid)}") + else + false -> m.replyfun.("not loaded") + nil -> m.replyfun.("not active") + end + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "liquidrender", m = %{trigger: %{args: args}}}, _) do + template = Enum.join(args, " ") + m.replyfun.(Tmpl.render(template, m)) + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "help", m = %{trigger: %{type: :bang}}}, _) do + url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index, m.network, NolaWeb.format_chan(m.channel)) + m.replyfun.("-> #{url}") + {:noreply, nil} + end + + def handle_info({:irc, :trigger, "version", message = %{trigger: %{type: :bang}}}, _) do + {:ok, vsn} = :application.get_key(:nola, :vsn) + ver = List.to_string(vsn) + url = NolaWeb.Router.Helpers.irc_url(NolaWeb.Endpoint, :index) + elixir_ver = Application.started_applications() |> List.keyfind(:elixir, 0) |> elem(2) |> to_string() + otp_ver = :erlang.system_info(:system_version) |> to_string() |> String.trim() + system = :erlang.system_info(:system_architecture) |> to_string() + brand = Nola.brand(:name) + owner = "#{Nola.brand(:owner)} <#{Nola.brand(:owner_email)}>" + message.replyfun.([ + <<"🤖 I am a robot running", 2, "#{brand}, version #{ver}", 2, " — source: #{Nola.source_url()}">>, + "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", + "👷♀️ Owner: h#{owner}", + "🌍 Web interface: #{url}" + ]) + {:noreply, nil} + end + + def handle_info(msg, _) do + {:noreply, nil} + end + +end diff --git a/lib/plugins/boursorama.ex b/lib/plugins/boursorama.ex new file mode 100644 index 0000000..f872584 --- /dev/null +++ b/lib/plugins/boursorama.ex @@ -0,0 +1,58 @@ +defmodule Nola.Plugins.Boursorama do + + def irc_doc() do + """ + # bourses + + Un peu comme [finance](#finance), mais en un peu mieux, et un peu moins bien. + + Source: [boursorama.com](https://boursorama.com) + + * **!caca40** affiche l'état du cac40 + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @cac40_url "https://www.boursorama.com/bourse/actions/palmares/france/?france_filter%5Bmarket%5D=1rPCAC&france_filter%5Bsector%5D=&france_filter%5Bvariation%5D=50002&france_filter%5Bperiod%5D=1&france_filter%5Bfilter%5D=" + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:cac40", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, cac, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) when cac in ["cac40", "caca40"] do + case HTTPoison.get(@cac40_url, [], []) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + html = Floki.parse(body) + board = Floki.find(body, "div.c-tradingboard") + + cac40 = Floki.find(board, ".c-tradingboard__main > .c-tradingboard__infos") + instrument = Floki.find(cac40, ".c-instrument") + last = Floki.find(instrument, "span[data-ist-last]") + |> Floki.text() + |> String.replace(" ", "") + variation = Floki.find(instrument, "span[data-ist-variation]") + |> Floki.text() + + sign = case variation do + "-"<>_ -> "▼" + "+" -> "▲" + _ -> "" + end + + m.replyfun.("caca40: #{sign} #{variation} #{last}") + + {:error, %HTTPoison.Response{status_code: code}} -> + m.replyfun.("caca40: erreur http #{code}") + + _ -> + m.replyfun.("caca40: erreur http") + end + end + +end diff --git a/lib/plugins/buffer.ex b/lib/plugins/buffer.ex new file mode 100644 index 0000000..67aea35 --- /dev/null +++ b/lib/plugins/buffer.ex @@ -0,0 +1,44 @@ +defmodule Nola.Plugins.Buffer do + @table __MODULE__.ETS + def irc_doc, do: nil + + def table(), do: @table + + def select_buffer(network, channel, limit \\ 50) do + import Ex2ms + spec = fun do {{n, c, _}, m} when n == ^network and (c == ^channel or is_nil(c)) -> m end + :ets.select(@table, spec, limit) + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + for e <- ~w(messages triggers events outputs) do + {:ok, _} = Registry.register(IRC.PubSub, e, plugin: __MODULE__) + end + {:ok, :ets.new(@table, [:named_table, :ordered_set, :protected])} + end + + def handle_info({:irc, :trigger, _, message}, ets), do: handle_message(message, ets) + def handle_info({:irc, :text, message}, ets), do: handle_message(message, ets) + def handle_info({:irc, :event, event}, ets), do: handle_message(event, ets) + + defp handle_message(message = %{network: network}, ets) do + key = {network, Map.get(message, :channel), ts(message.at)} + :ets.insert(ets, {key, message}) + {:noreply, ets} + end + + defp ts(nil), do: ts(NaiveDateTime.utc_now()) + + defp ts(naive = %NaiveDateTime{}) do + ts = naive + |> DateTime.from_naive!("Etc/UTC") + |> DateTime.to_unix() + + -ts + end + +end diff --git a/lib/plugins/calc.ex b/lib/plugins/calc.ex new file mode 100644 index 0000000..91fbbae --- /dev/null +++ b/lib/plugins/calc.ex @@ -0,0 +1,37 @@ +defmodule Nola.Plugins.Calc do + @moduledoc """ + # calc + + * **!calc `<expression>`**: évalue l'expression mathématique `<expression>`. + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:calc", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "calc", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: expr_list}}}, state) do + expr = Enum.join(expr_list, " ") + result = try do + case Abacus.eval(expr) do + {:ok, result} -> result + error -> inspect(error) + end + rescue + error -> if(error[:message], do: "#{error.message}", else: "erreur") + end + message.replyfun.("#{message.sender.nick}: #{expr} = #{result}") + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + +end diff --git a/lib/plugins/coronavirus.ex b/lib/plugins/coronavirus.ex new file mode 100644 index 0000000..624bae7 --- /dev/null +++ b/lib/plugins/coronavirus.ex @@ -0,0 +1,172 @@ +defmodule Nola.Plugins.Coronavirus do + require Logger + NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") + @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__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", [plugin: __MODULE__]) + {:ok, nil, {:continue, :init}} + :ignore + end + + def handle_continue(:init, _) do + date = Date.add(Date.utc_today(), -2) + {data, _} = fetch_data(%{}, date) + {data, next} = fetch_data(data) + :timer.send_after(next, :update) + {:noreply, %{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"], ["n"], ["nmorts"], ["nsoignés"], ["nconfirmés"]] do + {field, name} = case args do + ["confirmés"] -> {:confirmed, "confirmés"} + ["morts"] -> {:deaths, "morts"} + ["soignés"] -> {:recovered, "soignés"} + ["nmorts"] -> {:new_deaths, "nouveaux morts"} + ["nconfirmés"] -> {:new_confirmed, "nouveaux confirmés"} + ["n"] -> {:new_current, "nouveaux malades"} + ["nsoignés"] -> {:new_recovered, "nouveaux soignés"} + _ -> {:current, "malades"} + end + IO.puts("FIELD #{inspect field}") + field_evol = String.to_atom("new_#{field}") + sorted = state.data + |> Enum.filter(fn({_, %{region: region}}) -> region == true end) + |> Enum.map(fn({location, data}) -> {location, Map.get(data, field, 0), Map.get(data, field_evol, 0)} end) + |> Enum.sort_by(fn({_,count,_}) -> count end, &>=/2) + |> Enum.take(10) + |> Enum.with_index() + |> Enum.map(fn({{location, count, evol}, index}) -> + ev = if String.starts_with?(name, "nouveaux") do + "" + else + " (#{Util.plusminus(evol)})" + end + "##{index+1}: #{location} #{count}#{ev}" + end) + |> Enum.intersperse(" - ") + |> Enum.join() + m.replyfun.("CORONAVIRUS TOP10 #{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.("coronavirus: #{location}: " + <> "#{data.current} malades (#{Util.plusminus(data.new_current)}), " + <> "#{data.confirmed} confirmés (#{Util.plusminus(data.new_confirmed)}), " + <> "#{data.deaths} morts (#{Util.plusminus(data.new_deaths)}), " + <> "#{data.recovered} soignés (#{Util.plusminus(data.new_recovered)}) (@ #{data.update})") + 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 + |> CovidCsv.parse_string() + |> Enum.reduce(%{}, fn(line, acc) -> + case line do + # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key + #0FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key,Incidence_Rate,Case-Fatality_Ratio + [_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key, _incidence_rate, _fatality_ratio] -> + 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} + + 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 + } + + changes = if old = Map.get(current_data, region) do + %{ + new_confirmed: region_entry.confirmed - old.confirmed, + new_current: region_entry.current - old.current, + new_deaths: region_entry.deaths - old.deaths, + new_recovered: region_entry.recovered - old.recovered, + } + else + %{new_confirmed: 0, new_current: 0, new_deaths: 0, new_recovered: 0} + end + + region_entry = Map.merge(region_entry, changes) + + acc = Map.put(acc, region, region_entry) + + acc = if state && state != "" do + Map.put(acc, state, entry) + else + acc + end + + other -> + Logger.info("Coronavirus line failed: #{inspect line}") + acc + end + 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/plugins/correction.ex b/lib/plugins/correction.ex new file mode 100644 index 0000000..03968c8 --- /dev/null +++ b/lib/plugins/correction.ex @@ -0,0 +1,59 @@ +defmodule Nola.Plugins.Correction do + @moduledoc """ + # correction + + * `s/pattern/replace` replace `pattern` by `replace` in the last matching message + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) + {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) + {:ok, %{}} + end + + # Trigger fallback + def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do + {:noreply, correction(m, state)} + end + + def handle_info({:irc, :text, m = %IRC.Message{}}, state) do + {:noreply, correction(m, state)} + end + + def correction(m, state) do + history = Map.get(state, key(m), []) + if String.starts_with?(m.text, "s/") do + case String.split(m.text, "/") do + ["s", match, replace | _] -> + case Regex.compile(match) do + {:ok, reg} -> + repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) + if repl do + new_text = String.replace(repl.text, reg, replace) + m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}") + end + _ -> + m.replyfun.("correction: invalid regex") + end + _ -> m.replyfun.("correction: invalid regex format") + end + state + else + history = if length(history) > 100 do + {_, history} = List.pop_at(history, 99) + [m | history] + else + [m | history] + end + Map.put(state, key(m), history) + end + end + + defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" + +end diff --git a/lib/plugins/dice.ex b/lib/plugins/dice.ex new file mode 100644 index 0000000..2c1515b --- /dev/null +++ b/lib/plugins/dice.ex @@ -0,0 +1,66 @@ +defmodule Nola.Plugins.Dice 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__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:dice", [plugin: __MODULE__]) + {: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/plugins/finance.ex b/lib/plugins/finance.ex new file mode 100644 index 0000000..d6f890a --- /dev/null +++ b/lib/plugins/finance.ex @@ -0,0 +1,190 @@ +defmodule Nola.Plugins.Finance 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 "http://www.alphavantage.co/physical_currency_list/" + @crypto_list "http://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__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:forex", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:currency", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:stocks", regopts) + {: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(:nola, :alphavantage, []) + |> Keyword.get(:api_key, "demo") + end + +end diff --git a/lib/plugins/gpt.ex b/lib/plugins/gpt.ex new file mode 100644 index 0000000..3d2a66d --- /dev/null +++ b/lib/plugins/gpt.ex @@ -0,0 +1,259 @@ +defmodule Nola.Plugins.Gpt do + require Logger + import Nola.Plugins.TempRefHelper + + def irc_doc() do + """ + # OpenAI GPT + + Uses OpenAI's GPT-3 API to bring natural language prompts to your IRC channel. + + _prompts_ are pre-defined prompts and parameters defined in the bot' CouchDB. + + _Runs_ (results of the inference of a _prompt_) are also stored in CouchDB and + may be resumed. + + * **!gpt** list GPT prompts + * **!gpt `[prompt]` `<prompt or args>`** run a prompt + * **+gpt `[short ref|run id]` `<prompt or args>`** continue a prompt + * **?gpt offensive `<content>`** is content offensive ? + * **?gpt show `[short ref|run id]`** run information and web link + * **?gpt `[prompt]`** prompt information and web link + """ + end + + @couch_db "bot-plugin-openai-prompts" + @couch_run_db "bot-plugin-gpt-history" + @trigger "gpt" + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + defstruct [:temprefs] + + def get_result(id) do + Couch.get(@couch_run_db, id) + end + + def get_prompt(id) do + Couch.get(@couch_db, id) + end + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{@trigger}", regopts) + {:ok, %__MODULE__{temprefs: new_temp_refs()}} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [prompt | args]}}}, state) do + case Couch.get(@couch_db, prompt) do + {:ok, prompt} -> {:noreply, prompt(m, prompt, Enum.join(args, " "), state)} + {:error, :not_found} -> + m.replyfun.("gpt: prompt '#{prompt}' does not exists") + {:noreply, state} + error -> + Logger.info("gpt: prompt load error: #{inspect error}") + m.replyfun.("gpt: database error") + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do + case Couch.get(@couch_db, "_all_docs") do + {:ok, %{"rows" => []}} -> m.replyfun.("gpt: no prompts available") + {:ok, %{"rows" => prompts}} -> + prompts = prompts |> Enum.map(fn(prompt) -> Map.get(prompt, "id") end) |> Enum.join(", ") + m.replyfun.("gpt: prompts: #{prompts}") + error -> + Logger.info("gpt: prompt load error: #{inspect error}") + m.replyfun.("gpt: database error") + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [ref_or_id | args]}}}, state) do + id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) + case Couch.get(@couch_run_db, id) do + {:ok, run} -> + Logger.debug("+gpt run: #{inspect run}") + {:noreply, continue_prompt(m, run, Enum.join(args, " "), state)} + {:error, :not_found} -> + m.replyfun.("gpt: ref or id not found or expired: #{inspect ref_or_id} (if using short ref, try using full id)") + {:noreply, state} + error -> + Logger.info("+gpt: run load error: #{inspect error}") + m.replyfun.("gpt: database error") + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["offensive" | text]}}}, state) do + text = Enum.join(text, " ") + {moderate?, moderation} = moderation(text, m.account.id) + reply = cond do + moderate? -> "⚠️ #{Enum.join(moderation, ", ")}" + !moderate? && moderation -> "👍" + !moderate? -> "☠️ error" + end + m.replyfun.(reply) + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: ["show", ref_or_id]}}}, state) do + id = lookup_temp_ref(ref_or_id, state.temprefs, ref_or_id) + url = if m.channel do + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, m.network, NolaWeb.format_chan(m.channel), id) + else + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :result, id) + end + m.replyfun.("→ #{url}") + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :query, args: [prompt]}}}, state) do + url = if m.channel do + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, m.network, NolaWeb.format_chan(m.channel), prompt) + else + NolaWeb.Router.Helpers.gpt_url(NolaWeb.Endpoint, :prompt, prompt) + end + m.replyfun.("→ #{url}") + {:noreply, state} + end + + def handle_info(info, state) do + Logger.debug("gpt: unhandled info: #{inspect info}") + {:noreply, state} + end + + defp continue_prompt(msg, run, content, state) do + prompt_id = Map.get(run, "prompt_id") + prompt_rev = Map.get(run, "prompt_rev") + + original_prompt = case Couch.get(@couch_db, prompt_id, rev: prompt_rev) do + {:ok, prompt} -> prompt + _ -> nil + end + + if original_prompt do + continue_prompt = %{"_id" => prompt_id, + "_rev" => prompt_rev, + "type" => Map.get(original_prompt, "type"), + "parent_run_id" => Map.get(run, "_id"), + "openai_params" => Map.get(run, "request") |> Map.delete("prompt")} + + continue_prompt = if prompt_string = Map.get(original_prompt, "continue_prompt") do + full_text = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") + continue_prompt + |> Map.put("prompt", prompt_string) + |> Map.put("prompt_format", "liquid") + |> Map.put("prompt_liquid_variables", %{"previous" => full_text}) + else + prompt_content_tag = if content != "", do: " {{content}}", else: "" + string = get_in(run, ~w(request prompt)) <> "\n" <> Map.get(run, "response") <> prompt_content_tag + continue_prompt + |> Map.put("prompt", string) + |> Map.put("prompt_format", "liquid") + end + + prompt(msg, continue_prompt, content, state) + else + msg.replyfun.("gpt: cannot continue this prompt: original prompt not found #{prompt_id}@v#{prompt_rev}") + state + end + end + + defp prompt(msg, prompt = %{"type" => "completions", "prompt" => prompt_template}, content, state) do + Logger.debug("gpt:prompt/4 #{inspect prompt}") + prompt_text = case Map.get(prompt, "prompt_format", "liquid") do + "liquid" -> Tmpl.render(prompt_template, msg, Map.merge(Map.get(prompt, "prompt_liquid_variables", %{}), %{"content" => content})) + "norender" -> prompt_template + end + + args = Map.get(prompt, "openai_params") + |> Map.put("prompt", prompt_text) + |> Map.put("user", msg.account.id) + + {moderate?, moderation} = moderation(content, msg.account.id) + if moderate?, do: msg.replyfun.("⚠️ offensive input: #{Enum.join(moderation, ", ")}") + + Logger.debug("GPT: request #{inspect args}") + case OpenAi.post("/v1/completions", args) do + {:ok, %{"choices" => [%{"text" => text, "finish_reason" => finish_reason} | _], "usage" => usage, "id" => gpt_id, "created" => created}} -> + text = String.trim(text) + {o_moderate?, o_moderation} = moderation(text, msg.account.id) + if o_moderate?, do: msg.replyfun.("🚨 offensive output: #{Enum.join(o_moderation, ", ")}") + msg.replyfun.(text) + doc = %{"id" => FlakeId.get(), + "prompt_id" => Map.get(prompt, "_id"), + "prompt_rev" => Map.get(prompt, "_rev"), + "network" => msg.network, + "channel" => msg.channel, + "nick" => msg.sender.nick, + "account_id" => (if msg.account, do: msg.account.id), + "request" => args, + "response" => text, + "message_at" => msg.at, + "reply_at" => DateTime.utc_now(), + "gpt_id" => gpt_id, + "gpt_at" => created, + "gpt_usage" => usage, + "type" => "completions", + "parent_run_id" => Map.get(prompt, "parent_run_id"), + "moderation" => %{"input" => %{flagged: moderate?, categories: moderation}, + "output" => %{flagged: o_moderate?, categories: o_moderation} + } + } + Logger.debug("Saving result to couch: #{inspect doc}") + {id, ref, temprefs} = case Couch.post(@couch_run_db, doc) do + {:ok, id, _rev} -> + {ref, temprefs} = put_temp_ref(id, state.temprefs) + {id, ref, temprefs} + error -> + Logger.error("Failed to save to Couch: #{inspect error}") + {nil, nil, state.temprefs} + end + stop = cond do + finish_reason == "stop" -> "" + finish_reason == "length" -> " — truncated" + true -> " — #{finish_reason}" + end + ref_and_prefix = if Map.get(usage, "completion_tokens", 0) == 0 do + "GPT had nothing else to say :( ↪ #{ref || "✗"}" + else + " ↪ #{ref || "✗"}" + end + msg.replyfun.(ref_and_prefix <> + stop <> + " — #{Map.get(usage, "total_tokens", 0)}" <> + " (#{Map.get(usage, "prompt_tokens", 0)}/#{Map.get(usage, "completion_tokens", 0)}) tokens" <> + " — #{id || "save failed"}") + %__MODULE__{state | temprefs: temprefs} + {:error, atom} when is_atom(atom) -> + Logger.error("gpt error: #{inspect atom}") + msg.replyfun.("gpt: ☠️ #{to_string(atom)}") + state + error -> + Logger.error("gpt error: #{inspect error}") + msg.replyfun.("gpt: ☠️ ") + state + end + end + + defp moderation(content, user_id) do + case OpenAi.post("/v1/moderations", %{"input" => content, "user" => user_id}) do + {:ok, %{"results" => [%{"flagged" => true, "categories" => categories} | _]}} -> + cat = categories + |> Enum.filter(fn({_key, value}) -> value end) + |> Enum.map(fn({key, _}) -> key end) + {true, cat} + {:ok, moderation} -> + Logger.debug("gpt: moderation: not flagged, #{inspect moderation}") + {false, true} + error -> + Logger.error("gpt: moderation error: #{inspect error}") + {false, false} + end + end + +end diff --git a/lib/plugins/helpers/temp_ref.ex b/lib/plugins/helpers/temp_ref.ex new file mode 100644 index 0000000..160169d --- /dev/null +++ b/lib/plugins/helpers/temp_ref.ex @@ -0,0 +1,95 @@ +defmodule Nola.Plugins.TempRefHelper do + @moduledoc """ + This module allows to easily implement local temporary simple references for easy access from IRC. + + For example, your plugin output could be acted on, and instead of giving the burden for the user to + write or copy that uuid, you could give them a small alphanumeric reference to use instead. + + You can configure how many and for how long the references are kept. + + ## Usage + + `import Irc.Plugin.TempRef` + + ```elixir + defmodule Irc.MyPlugin do + defstruct [:temprefs] + + def init(_) do + # … + {:ok, %__MODULE__{temprefs: new_temp_refs()} + end + end + ``` + """ + + defstruct [:refs, :max, :expire, :build_fun, :build_increase_fun, :build_options] + + defmodule SimpleAlphaNumericBuilder do + def build(options) do + length = Keyword.get(options, :length, 3) + for _ <- 1..length, into: "", do: <<Enum.random('bcdfghjkmpqtrvwxy2346789')>> + end + + def increase(options) do + Keyword.put(options, :length, Keyword.get(options, :length, 3) + 1) + end + end + + def new_temp_refs(options \\ []) do + %__MODULE__{ + refs: Keyword.get(options, :init_refs, []), + max: Keyword.get(options, :max, []), + expire: Keyword.get(options, :expire, :infinity), + build_fun: Keyword.get(options, :build_fun, &__MODULE__.SimpleAlphaNumericBuilder.build/1), + build_increase_fun: Keyword.get(options, :build_increase_fun, &__MODULE__.SimpleAlphaNumericBuilder.increase/1), + build_options: Keyword.get(options, :build_options, [length: 3]) + } + end + + def janitor_refs(state = %__MODULE__{}) do + if length(state.refs) > state.max do + %__MODULE__{refs: state.refs |> Enum.reverse() |> tl() |> Enum.reverse()} + else + state + end + end + + def put_temp_ref(data, state = %__MODULE__{}) do + state = janitor_refs(state) + key = new_nonexisting_key(state) + if key do + ref = {key, DateTime.utc_now(), data} + {key, %__MODULE__{state | refs: [ref | state.refs]}} + else + {nil, state} + end + end + + def lookup_temp_ref(key, state, default \\ nil) do + case List.keyfind(state.refs, key, 0) do + {_, _, data} -> data + _ -> default + end + end + + defp new_nonexisting_key(state, i) when i > 50 do + nil + end + + defp new_nonexisting_key(state = %__MODULE__{refs: refs}, i \\ 1) do + build_options = if rem(i, 5) == 0 do + state.build_increase_fun.(state.build_options) + else + state.build_options + end + + key = state.build_fun.(state.build_options) + if !List.keymember?(refs, key, 0) do + key + else + new_nonexisting_key(state, i + 1) + end + end + +end diff --git a/lib/plugins/kick_roulette.ex b/lib/plugins/kick_roulette.ex new file mode 100644 index 0000000..f6d2ba2 --- /dev/null +++ b/lib/plugins/kick_roulette.ex @@ -0,0 +1,32 @@ +defmodule Nola.Plugins.KickRoulette do + @moduledoc """ + # kick roulette + + * **!kick**, tentez votre chance… + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:kick", [plugin: __MODULE__]) + {: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/plugins/last_fm.ex b/lib/plugins/last_fm.ex new file mode 100644 index 0000000..1a9b7dd --- /dev/null +++ b/lib/plugins/last_fm.ex @@ -0,0 +1,187 @@ +defmodule Nola.Plugins.LastFm do + require Logger + + @moduledoc """ + # last.fm + + * **!lastfm|np `[nick|username]`** + * **.lastfm|np** + * **+lastfm, -lastfm `<username last.fm>; ?lastfm`** Configurer un nom d'utilisateur last.fm + """ + + @single_trigger ~w(lastfm np) + @pubsub_topics ~w(trigger:lastfm trigger:np) + + defstruct dets: nil + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [type: __MODULE__] + for t <- @pubsub_topics, do: {:ok, _} = Registry.register(IRC.PubSub, t, type: __MODULE__) + dets_filename = (Nola.data_path() <> "/" <> "lastfm.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %__MODULE__{dets: dets}} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :plus, args: [username]}}}, state) do + username = String.strip(username) + :ok = :dets.insert(state.dets, {message.account.id, username}) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm configuré: \"#{username}\".") + {:noreply, state} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :minus, args: []}}}, state) do + text = case :dets.lookup(state.dets, message.account.id) do + [{_nick, _username}] -> + :dets.delete(state.dets, message.account.id) + message.replyfun.("#{message.sender.nick}: nom d'utilisateur last.fm enlevé.") + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, "lastfm", message = %{trigger: %{type: :query, args: []}}}, state) do + text = case :dets.lookup(state.dets, message.account.id) do + [{_nick, username}] -> + message.replyfun.("#{message.sender.nick}: #{username}.") + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: []}}}, state) do + irc_now_playing(message.account.id, message, state) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :bang, args: [nick_or_user]}}}, state) do + irc_now_playing(nick_or_user, message, state) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, message = %{trigger: %{type: :dot}}}, state) do + members = IRC.Membership.members(message.network, message.channel) + foldfun = fn({nick, user}, acc) -> [{nick,user}|acc] end + usernames = :dets.foldl(foldfun, [], state.dets) + |> Enum.uniq() + |> Enum.filter(fn({acct,_}) -> Enum.member?(members, acct) end) + |> Enum.map(fn({_, u}) -> u end) + for u <- usernames, do: irc_now_playing(u, message, state) + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + + def terminate(_reason, state) do + if state.dets do + :dets.sync(state.dets) + :dets.close(state.dets) + end + :ok + end + + defp irc_now_playing(nick_or_user, message, state) do + nick_or_user = String.strip(nick_or_user) + + id_or_user = if account = IRC.Account.get(nick_or_user) || IRC.Account.find_always_by_nick(message.network, message.channel, nick_or_user) do + account.id + else + nick_or_user + end + + username = case :dets.lookup(state.dets, id_or_user) do + [{_, username}] -> username + _ -> id_or_user + end + + case now_playing(username) do + {:error, text} when is_binary(text) -> + message.replyfun.(text) + {:ok, map} when is_map(map) -> + track = fetch_track(username, map) + text = format_now_playing(map, track) + user = if account = IRC.Account.get(id_or_user) do + user = IRC.UserTrack.find_by_account(message.network, account) + if(user, do: user.nick, else: account.name) + else + username + end + if user && text do + message.replyfun.("#{user} #{text}") + else + message.replyfun.("#{username}: pas de résultat") + end + other -> + message.replyfun.("erreur :(") + end + end + + defp now_playing(user) do + api = Application.get_env(:nola, :lastfm)[:api_key] + url = "http://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&format=json&limit=1&extended=1" <> "&api_key=" <> api <> "&user="<> user + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> Jason.decode(body) + {:ok, %HTTPoison.Response{status_code: 404}} -> {:error, "last.fm: utilisateur \"#{user}\" inexistant"} + {:ok, %HTTPoison.Response{status_code: code}} -> {:error, "last.fm: erreur #{to_string(code)}"} + error -> + Logger.error "Lastfm http error: #{inspect error}" + :error + end + end + defp fetch_track(user, %{"recenttracks" => %{"track" => [ t = %{"name" => name, "artist" => %{"name" => artist}} | _]}}) do + api = Application.get_env(:nola, :lastfm)[:api_key] + url = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json" <> "&api_key=" <> api <> "&username="<> user <> "&artist="<>URI.encode(artist)<>"&track="<>URI.encode(name) + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + case Jason.decode(body) do + {:ok, body} -> body["track"] || %{} + _ -> %{} + end + error -> + Logger.error "Lastfm http error: #{inspect error}" + :error + end + end + + defp format_now_playing(%{"recenttracks" => %{"track" => [track = %{"@attr" => %{"nowplaying" => "true"}} | _]}}, et) do + format_track(true, track, et) + end + + defp format_now_playing(%{"recenttracks" => %{"track" => [track | _]}}, et) do + format_track(false, track, et) + end + + defp format_now_playing(%{"error" => err, "message" => message}, _) do + "last.fm error #{err}: #{message}" + end + + defp format_now_playing(miss) do + nil + end + + defp format_track(np, track, extended) do + artist = track["artist"]["name"] + album = if track["album"]["#text"], do: " (" <> track["album"]["#text"] <> ")", else: "" + name = track["name"] <> album + action = if np, do: "écoute ", else: "a écouté" + love = if track["loved"] != "0", do: "❤️" + count = if x = extended["userplaycount"], do: "x#{x} #{love}" + tags = (get_in(extended, ["toptags", "tag"]) || []) + |> Enum.map(fn(tag) -> tag["name"] end) + |> Enum.filter(& &1) + |> Enum.join(", ") + + [action, artist, name, count, tags, track["url"]] + |> Enum.filter(& &1) + |> Enum.map(&String.trim(&1)) + |> Enum.join(" - ") + end + +end diff --git a/lib/plugins/link.ex b/lib/plugins/link.ex new file mode 100644 index 0000000..381bdab --- /dev/null +++ b/lib/plugins/link.ex @@ -0,0 +1,271 @@ +defmodule Nola.Plugins.Link do + @moduledoc """ + # Link Previewer + + An extensible link previewer for IRC. + + To extend the supported sites, create a new handler implementing the callbacks. + + See `link/` directory. 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 :nola, Nola.Plugins.Link, + handlers: [ + Nola.Plugins.Link.Youtube: [ + invidious: true + ], + Nola.Plugins.Link.Twitter: [], + Nola.Plugins.Link.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__, [], name: __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 + @callback post_match(uri :: URI.t, content_type :: binary, headers :: [], opts :: Keyword.t) :: {:body | :file, params :: Map.t} | false + @callback post_expand(uri :: URI.t, body :: binary() | Path.t, params :: Map.t, options :: Keyword.t) :: {:ok, lines :: [] | String.t} | :error + + @optional_callbacks [expand: 3, post_expand: 4] + + defstruct [:client] + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) + #{:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", [plugin: __MODULE__]) + 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() -> + :timer.kill_after(:timer.seconds(30)) + case expand_link([uri]) do + {:ok, uris, text} -> + text = case uris do + [uri] -> text + [luri | _] -> + if luri.host == uri.host && luri.path == luri.path do + text + else + ["-> #{URI.to_string(luri)}", text] + end + end + if is_list(text) do + for line <- text, do: message.replyfun.(line) + else + message.replyfun.(text) + end + _ -> 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(acc = [_, _, _, _, _ | _]) do + {:ok, acc, "link redirects more than five times"} + end + + def expand_link(acc=[uri | _]) do + Logger.debug("link: expanding: #{inspect uri}") + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> + Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}") + 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 + Logger.debug("link: expanding #{inspect uri} with #{inspect module}") + case module.expand(uri, params, opts) do + {:ok, data} -> {:ok, acc, data} + :error -> expand_default(acc) + :skip -> nil + end + rescue + e -> + Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}") + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + expand_default(acc) + catch + e, b -> + Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}") + expand_default(acc) + end + + defp get(url, headers \\ [], options \\ []) do + get_req(url, :hackney.get(url, headers, <<>>, options)) + end + + defp get_req(_, {:error, reason}) do + {:error, reason} + end + + defp get_req(url, {:ok, 200, headers, client}) do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + content_type = Map.get(headers, "content-type", "application/octect-stream") + length = Map.get(headers, "content-length", "0") + {length, _} = Integer.parse(length) + + handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers) + handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) -> + module = Module.concat([module]) + try do + case module.post_match(url, content_type, headers, opts) do + {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}} + false -> {:cont, acc} + end + rescue + e -> + Logger.error(inspect(e)) + {:cont, false} + catch + e, b -> + Logger.error(inspect({b})) + {:cont, false} + end + end) + + cond do + handler != false and length <= 30_000_000 -> + case get_body(url, 30_000_000, client, handler, <<>>) do + {:ok, _} = ok -> ok + :error -> + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + #String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> + # get_body(url, 30_000_000, client, <<>>) + true -> + :hackney.close(client) + {:ok, "file: #{content_type}, size: #{human_size(length)}"} + end + end + + defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do + headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> + Map.put(acc, String.downcase(key), value) + end) + location = Map.get(headers, "location") + + :hackney.close(client) + {:redirect, location} + end + + defp get_req(_, {:ok, status, headers, client}) do + :hackney.close(client) + {:error, status, headers} + end + + defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do + case :hackney.stream_body(client) do + {:ok, data} -> + get_body(url, len, client, h, << acc::binary, data::binary >>) + :done -> + body = case mode do + :body -> acc + :file -> + {:ok, tmpfile} = Plug.Upload.random_file("linkplugin") + File.write!(tmpfile, acc) + tmpfile + end + handler.post_expand(url, body, params, opts) + {:error, reason} -> + {:ok, "failed to fetch body: #{inspect reason}"} + end + end + + defp get_body(_, len, client, h, _acc) do + :hackney.close(client) + IO.inspect(h) + {:ok, "Error: file over 30"} + end + + def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do + Logger.debug("link: expanding #{uri} with default") + headers = [{"user-agent", "DmzBot (like TwitterBot)"}] + options = [follow_redirect: false, max_body_length: 30_000_000] + case get(URI.to_string(uri), headers, options) do + {:ok, text} -> + {:ok, acc, text} + {:redirect, link} -> + new_uri = URI.parse(link) + #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port} + expand_link([new_uri | acc]) + {:error, status, _headers} -> + text = Plug.Conn.Status.reason_phrase(status) + {:ok, acc, "Error: HTTP #{text} (#{status})"} + {:error, {:tls_alert, {:handshake_failure, err}}} -> + {:ok, acc, "TLS Error: #{to_string(err)}"} + {:error, reason} -> + {:ok, acc, "Error: #{to_string(reason)}"} + end + end + + # Unsupported scheme, came from a redirect. + def expand_default(acc = [uri | _]) do + {:ok, [uri], "-> #{URI.to_string(uri)}"} + end + + + defp human_size(bytes) do + bytes + |> FileSize.new(:b) + |> FileSize.scale() + |> FileSize.format() + end +end diff --git a/lib/plugins/link/github.ex b/lib/plugins/link/github.ex new file mode 100644 index 0000000..0069a40 --- /dev/null +++ b/lib/plugins/link/github.ex @@ -0,0 +1,49 @@ +defmodule Nola.Plugins.Link.Github do + @behaviour Nola.Plugins.Link + + @impl true + def match(uri = %URI{host: "github.com", path: path}, _) do + case String.split(path, "/") do + ["", user, repo] -> + {true, %{user: user, repo: repo, path: "#{user}/#{repo}"}} + _ -> + false + end + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(_uri, %{user: user, repo: repo}, _opts) do + case HTTPoison.get("https://api.github.com/repos/#{user}/#{repo}") do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + {:ok, json} = Jason.decode(body) + src = json["source"]["full_name"] + disabled = if(json["disabled"], do: " (disabled)", else: "") + archived = if(json["archived"], do: " (archived)", else: "") + fork = if src && src != json["full_name"] do + " (⑂ #{json["source"]["full_name"]})" + else + "" + end + start = "#{json["full_name"]}#{disabled}#{archived}#{fork} - #{json["description"]}" + tags = for(t <- json["topics"]||[], do: "##{t}") |> Enum.intersperse(", ") |> Enum.join("") + lang = if(json["language"], do: "#{json["language"]} - ", else: "") + issues = if(json["open_issues_count"], do: "#{json["open_issues_count"]} issues - ", else: "") + last_push = if at = json["pushed_at"] do + {:ok, date, _} = DateTime.from_iso8601(at) + " - last pushed #{DateTime.to_string(date)}" + else + "" + end + network = "#{lang}#{issues}#{json["stargazers_count"]} stars - #{json["subscribers_count"]} watchers - #{json["forks_count"]} forks#{last_push}" + {:ok, [start, tags, network]} + other -> + :error + end + end + +end diff --git a/lib/plugins/link/html.ex b/lib/plugins/link/html.ex new file mode 100644 index 0000000..9b44319 --- /dev/null +++ b/lib/plugins/link/html.ex @@ -0,0 +1,106 @@ +defmodule Nola.Plugins.Link.HTML do + @behaviour Nola.Plugins.Link + + @impl true + def match(_, _), do: false + + @impl true + def post_match(_url, "text/html"<>_, _header, _opts) do + {:body, nil} + end + def post_match(_, _, _, _), do: false + + @impl true + def post_expand(url, body, _params, _opts) do + html = Floki.parse(body) + title = collect_title(html) + opengraph = collect_open_graph(html) + itemprops = collect_itemprops(html) + text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do + sitename = if sn = Map.get(opengraph, "site_name") do + "#{sn}" + else + "" + end + paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do + "" + else + "[paywall] " + end + section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do + ": #{section}" + else + "" + end + date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do + {:ok, date, _} -> + "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " + _ -> + "" + end + uri = URI.parse(url) + + prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" + prefix = unless prefix == "" do + "#{prefix} — " + else + "" + end + [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ IRC.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) + else + clean_text(title) + end + {:ok, text} + end + + defp collect_title(html) do + case Floki.find(html, "title") do + [{"title", [], [title]} | _] -> + String.trim(title) + _ -> + nil + end + end + + defp collect_open_graph(html) do + Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> + case tag do + {"meta", values, []} -> + name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) + content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) + case name do + "og:" <> key -> + Map.put(acc, key, content) + "article:"<>_ -> + Map.put(acc, name, content) + _other -> acc + end + _other -> acc + end + end) + end + + defp collect_itemprops(html) do + Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> + case tag do + {"meta", values, []} -> + name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) + content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) + case name do + "article:" <> key -> + Map.put(acc, name, content) + _other -> acc + end + _other -> acc + end + end) + end + + defp clean_text(text) do + text + |> String.replace("\n", " ") + |> HtmlEntities.decode() + end + + +end diff --git a/lib/plugins/link/imgur.ex b/lib/plugins/link/imgur.ex new file mode 100644 index 0000000..9fe9354 --- /dev/null +++ b/lib/plugins/link/imgur.ex @@ -0,0 +1,96 @@ +defmodule Nola.Plugins.Link.Imgur do + @behaviour Nola.Plugins.Link + + @moduledoc """ + # Imgur link preview + + No options. + + Needs to have a Imgur API key configured: + + ``` + config :nola, :imgur, + client_id: "xxxxxxxx", + client_secret: "xxxxxxxxxxxxxxxxxxxx" + ``` + """ + + @impl true + def match(uri = %URI{host: "imgur.io"}, arg) do + match(%URI{uri | host: "imgur.com"}, arg) + end + def match(uri = %URI{host: "i.imgur.io"}, arg) do + match(%URI{uri | host: "i.imgur.com"}, arg) + end + 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 + + @impl true + def post_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(:nola, :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"] + title = String.slice(data["title"] || data["description"], 0, 180) + nsfw = if data["nsfw"], do: "(NSFW) - ", else: " " + height = Map.get(data, "height") + width = Map.get(data, "width") + size = Map.get(data, "size") + {:ok, "image, #{width}x#{height}, #{size} bytes #{nsfw}#{title}"} + other -> + :error + end + end + + def expand_imgur_album(album_id, opts) do + client_id = Keyword.get(Application.get_env(:nola, :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/plugins/link/pdf.ex b/lib/plugins/link/pdf.ex new file mode 100644 index 0000000..e91dcc2 --- /dev/null +++ b/lib/plugins/link/pdf.ex @@ -0,0 +1,39 @@ +defmodule Nola.Plugins.Link.PDF do + require Logger + @behaviour Nola.Plugins.Link + + @impl true + def match(_, _), do: false + + @impl true + def post_match(_url, "application/pdf"<>_, _header, _opts) do + {:file, nil} + end + + def post_match(_, _, _, _), do: false + + @impl true + def post_expand(url, file, _, _) do + case System.cmd("pdftitle", ["-p", file]) do + {text, 0} -> + text = text + |> String.trim() + + if text == "" do + :error + else + basename = Path.basename(url, ".pdf") + text = "[#{basename}] " <> text + |> String.split("\n") + {:ok, text} + end + {_, 127} -> + Logger.error("dependency `pdftitle` is missing, please install it: `pip3 install pdftitle`.") + :error + {error, code} -> + Logger.warn("command `pdftitle` exited with status code #{code}:\n#{inspect error}") + :error + end + end + +end diff --git a/lib/plugins/link/redacted.ex b/lib/plugins/link/redacted.ex new file mode 100644 index 0000000..a7cfe74 --- /dev/null +++ b/lib/plugins/link/redacted.ex @@ -0,0 +1,18 @@ +defmodule Nola.Plugins.Link.Redacted do + @behaviour Nola.Plugins.Link + + @impl true + def match(uri = %URI{host: "redacted.ch", path: "/torrent.php", query: query = "id="<>id}, _opts) do + %{"id" => id} = URI.decode_query(id) + {true, %{torrent: id}} + end + + def match(_, _), do: false + + @impl true + def post_match(_, _, _, _), do: false + + def expand(_uri, %{torrent: id}, _opts) do + end + +end diff --git a/lib/plugins/link/reddit.ex b/lib/plugins/link/reddit.ex new file mode 100644 index 0000000..016e025 --- /dev/null +++ b/lib/plugins/link/reddit.ex @@ -0,0 +1,119 @@ +defmodule Nola.Plugins.Link.Reddit do + @behaviour Nola.Plugins.Link + + @impl true + def match(uri = %URI{host: "reddit.com", path: path}, _) do + case String.split(path, "/") do + ["", "r", sub, "comments", post_id, _slug] -> + {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, "comments", post_id, _slug, ""] -> + {true, %{mode: :post, path: path, sub: sub, post_id: post_id}} + ["", "r", sub, ""] -> + {true, %{mode: :sub, path: path, sub: sub}} + ["", "r", sub] -> + {true, %{mode: :sub, path: path, sub: sub}} +# ["", "u", user] -> +# {true, %{mode: :user, path: path, user: user}} + _ -> + false + end + end + + def match(uri = %URI{host: host, path: path}, opts) do + if String.ends_with?(host, ".reddit.com") do + match(%URI{uri | host: "reddit.com"}, opts) + else + false + end + end + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(_, %{mode: :sub, sub: sub}, _opts) do + url = "https://api.reddit.com/r/#{sub}/about" + case HTTPoison.get(url) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + sr = Jason.decode!(body) + |> Map.get("data") + |> IO.inspect(limit: :infinity) + description = Map.get(sr, "public_description")||Map.get(sr, "description", "") + |> String.split("\n") + |> List.first() + name = if title = Map.get(sr, "title") do + Map.get(sr, "display_name_prefixed") <> ": " <> title + else + Map.get(sr, "display_name_prefixed") + end + nsfw = if Map.get(sr, "over18") do + "[NSFW] " + else + "" + end + quarantine = if Map.get(sr, "quarantine") do + "[Quarantined] " + else + "" + end + count = "#{Map.get(sr, "subscribers")} subscribers, #{Map.get(sr, "active_user_count")} active" + preview = "#{quarantine}#{nsfw}#{name} — #{description} (#{count})" + {:ok, preview} + _ -> + :error + end + end + + def expand(_uri, %{mode: :post, path: path, sub: sub, post_id: post_id}, _opts) do + case HTTPoison.get("https://api.reddit.com#{path}?sr_detail=true") do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + json = Jason.decode!(body) + op = List.first(json) + |> Map.get("data") + |> Map.get("children") + |> List.first() + |> Map.get("data") + |> IO.inspect(limit: :infinity) + sr = get_in(op, ["sr_detail", "display_name_prefixed"]) + {self?, url} = if Map.get(op, "selftext") == "" do + {false, Map.get(op, "url")} + else + {true, nil} + end + + self_str = if(self?, do: "text", else: url) + up = Map.get(op, "ups") + down = Map.get(op, "downs") + comments = Map.get(op, "num_comments") + nsfw = if Map.get(op, "over_18") do + "[NSFW] " + else + "" + end + state = cond do + Map.get(op, "hidden") -> "hidden" + Map.get(op, "archived") -> "archived" + Map.get(op, "locked") -> "locked" + Map.get(op, "quarantine") -> "quarantined" + Map.get(op, "removed_by") || Map.get(op, "removed_by_category") -> "removed" + Map.get(op, "banned_by") -> "banned" + Map.get(op, "pinned") -> "pinned" + Map.get(op, "stickied") -> "stickied" + true -> nil + end + flair = if flair = Map.get(op, "link_flair_text") do + "[#{flair}] " + else + "" + end + title = "#{nsfw}#{sr}: #{flair}#{Map.get(op, "title")}" + state_str = if(state, do: "#{state}, ") + content = "by u/#{Map.get(op, "author")} - #{state_str}#{up} up, #{down} down, #{comments} comments - #{self_str}" + + {:ok, [title, content]} + err -> + :error + end + end + +end diff --git a/lib/plugins/link/twitter.ex b/lib/plugins/link/twitter.ex new file mode 100644 index 0000000..e7f3e63 --- /dev/null +++ b/lib/plugins/link/twitter.ex @@ -0,0 +1,158 @@ +defmodule Nola.Plugins.Link.Twitter do + @behaviour Nola.Plugins.Link + + @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 + + @impl true + def post_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 link_tweet(tweet_or_screen_id_tuple, opts, force_twitter_com \\ false) + + defp link_tweet({screen_name, id}, opts, force_twitter_com) do + path = "/#{screen_name}/status/#{id}" + nitter = Keyword.get(opts, :nitter) + host = if !force_twitter_com && nitter, do: nitter, else: "twitter.com" + "https://#{host}/#{screen_name}/status/#{id}" + end + + defp link_tweet(tweet, opts, force_twitter_com) do + link_tweet({tweet.user.screen_name, tweet.id}, opts, force_twitter_com) + end + + defp expand_tweet(tweet, opts) do + head = format_tweet_header(tweet, opts) + + # Format tweet text + text = expand_twitter_text(tweet, opts) + text = if tweet.quoted_status do + quote_url = link_tweet(tweet.quoted_status, opts, true) + String.replace(text, quote_url, "") + else + text + end + text = IRC.splitlong(text) + + reply_to = if tweet.in_reply_to_status_id do + reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) + text = if tweet.in_reply_to_screen_name == tweet.user.screen_name, do: "continued from", else: "replying to" + <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> + end + + quoted = if tweet.quoted_status do + full_text = tweet.quoted_status + |> expand_twitter_text(opts) + |> IRC.splitlong_with_prefix(">") + + head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") + + [head | full_text] + else + [] + end + + #<<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted + + text = [head, reply_to | text] ++ quoted + |> Enum.filter(& &1) + {:ok, text} + end + + defp expand_twitter_text(tweet, _opts) 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) + |> HtmlEntities.decode() + end + + defp format_tweet_header(tweet, opts, format_opts \\ []) do + prefix = Keyword.get(format_opts, :prefix, nil) + details = Keyword.get(format_opts, :details, true) + + padded_prefix = if prefix, do: "#{prefix} ", else: "" + author = <<padded_prefix::binary, 2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2>> + + link = link_tweet(tweet, opts) + + {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) + {:ok, formatted_time} = Timex.format(at, "{relative}", :relative) + + nsfw = if tweet.possibly_sensitive, do: <<3, 52, "NSFW", 3>> + + rts = if tweet.retweet_count && tweet.retweet_count > 0, do: "#{tweet.retweet_count} RT" + likes = if tweet.favorite_count && tweet.favorite_count > 0, do: "#{tweet.favorite_count} ❤︎" + qrts = if tweet.quote_count && tweet.quote_count > 0, do: "#{tweet.quote_count} QRT" + replies = if tweet.reply_count && tweet.reply_count > 0, do: "#{tweet.reply_count} Reps" + + dmcad = if tweet.withheld_copyright, do: <<3, 52, "DMCA", 3>> + withheld_local = if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do + "Withheld in #{length(tweet.withheld_in_countries)} countries" + end + + verified = if tweet.user.verified, do: <<3, 51, "✔", 3>> + + meta = if details do + [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] + else + [verified, nsfw, formatted_time, dmcad, withheld_local] + end + + meta = meta + |> Enum.filter(& &1) + |> Enum.join(" - ") + + meta = <<3, 15, meta::binary, " → #{link}", 3>> + + <<author::binary, " — ", meta::binary>> + end + +end diff --git a/lib/plugins/link/youtube.ex b/lib/plugins/link/youtube.ex new file mode 100644 index 0000000..1b14221 --- /dev/null +++ b/lib/plugins/link/youtube.ex @@ -0,0 +1,72 @@ +defmodule Nola.Plugins.Link.YouTube do + @behaviour Nola.Plugins.Link + + @moduledoc """ + # YouTube link preview + + needs an API key: + + ``` + config :nola, :youtube, + api_key: "xxxxxxxxxxxxx" + ``` + + options: + + * `invidious`: Add a link to invidious. + """ + + @impl 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 + + @impl true + def post_match(_, _, _, _), do: false + + @impl true + def expand(uri, %{video_id: video_id}, opts) do + key = Application.get_env(:nola, :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 host = Keyword.get(opts, :invidious) do + ["-> https://#{host}/watch?v=#{video_id}"] + else + [] + end + {:ok, line ++ ["#{snippet["title"]}", "— #{duration} — uploaded by #{snippet["channelTitle"]} — #{date}" + <> " — #{item["statistics"]["viewCount"]} views, #{item["statistics"]["likeCount"]} likes"]} + else + :error + end + _ -> :error + end + end + end + +end diff --git a/lib/plugins/logger.ex b/lib/plugins/logger.ex new file mode 100644 index 0000000..69cac66 --- /dev/null +++ b/lib/plugins/logger.ex @@ -0,0 +1,71 @@ +defmodule Nola.Plugins.Logger do + require Logger + + @couch_db "bot-logs" + + def irc_doc(), do: nil + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:telegram", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "irc:outputs", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, _, m}, state) do + {:noreply, log(m, state)} + end + + def handle_info({:irc, :text, m}, state) do + {:noreply, log(m, state)} + end + + def handle_info(info, state) do + Logger.debug("logger_plugin: unhandled info: #{info}") + {:noreply, state} + end + + def log(entry, state) do + case Couch.post(@couch_db, format_to_db(entry)) do + {:ok, id, _rev} -> + Logger.debug("logger_plugin: saved: #{inspect id}") + state + error -> + Logger.error("logger_plugin: save failed: #{inspect error}") + end + rescue + e -> + Logger.error("logger_plugin: rescued processing for #{inspect entry}: #{inspect e}") + Logger.error(Exception.format(:error, e, __STACKTRACE__)) + state + catch + e, b -> + Logger.error("logger_plugin: catched processing for #{inspect entry}: #{inspect e}") + Logger.error(Exception.format(e, b, __STACKTRACE__)) + state + end + + def format_to_db(msg = %IRC.Message{id: id}) do + msg + |> Poison.encode!() + |> Map.drop("id") + + %{"_id" => id || FlakeId.get(), + "type" => "irc.message/v1", + "object" => msg} + end + + def format_to_db(anything) do + %{"_id" => FlakeId.get(), + "type" => "object", + "object" => anything} + end + +end diff --git a/lib/plugins/outline.ex b/lib/plugins/outline.ex new file mode 100644 index 0000000..f0ccfd2 --- /dev/null +++ b/lib/plugins/outline.ex @@ -0,0 +1,108 @@ +defmodule Nola.Plugins.Outline do + @moduledoc """ + # outline auto-link + + Envoie un lien vers Outline quand un lien est envoyé. + + * **!outline `<url>`** crée un lien outline pour `<url>`. + * **+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__, [], name: __MODULE__) + end + + defstruct [:file, :hosts] + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + file = Path.join(Nola.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, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do + line = "-> #{outline(url)}" + message.replyfun.(line) + 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 + outline_url = outline(word) + line = "-> #{outline_url}" + 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 + + def outline(url) do + unexpanded = "https://outline.com/#{url}" + headers = [ + {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"}, + {"Accept", "*/*"}, + {"Accept-Language", "en-US,en;q=0.5"}, + {"Origin", "https://outline.com"}, + {"DNT", "1"}, + {"Referer", unexpanded}, + {"Pragma", "no-cache"}, + {"Cache-Control", "no-cache"} + ] + params = %{"source_url" => url} + case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do + {:ok, %HTTPoison.Response{status_code: 200, body: json}} -> + body = Poison.decode!(json) + if Map.get(body, "success") do + code = get_in(body, ["data", "short_code"]) + "https://outline.com/#{code}" + else + unexpanded + end + error -> + Logger.info("outline.com error: #{inspect error}") + unexpanded + end + end + +end diff --git a/lib/plugins/preums.ex b/lib/plugins/preums.ex new file mode 100644 index 0000000..80c5af0 --- /dev/null +++ b/lib/plugins/preums.ex @@ -0,0 +1,276 @@ +defmodule Nola.Plugins.Preums do + @moduledoc """ + # preums !!! + + * `!preums`: affiche le preums du jour + * `.preums`: stats des preums + """ + + # WIP Scores + # L'idée c'est de donner un score pour mettre un peu de challenge en pénalisant les preums faciles. + # + # Un preums ne vaut pas 1 point, mais plutôt 0.10 ou 0.05, et on arrondi au plus proche. C'est un jeu sur le long + # terme. Un gros bonus pourrait apporter beaucoup de points. + # + # Il faudrait ces données: + # - moyenne des preums + # - activité récente du channel et par nb actifs d'utilisateurs + # (aggréger memberships+usertrack last_active ?) + # (faire des stats d'activité habituelle (un peu a la pisg) ?) + # - preums consécutifs + # + # Malus: + # - est proche de la moyenne en faible activité + # - trop consécutif de l'utilisateur sauf si activité + # + # Bonus: + # - plus le preums est éloigné de la moyenne + # - après 18h double + # - plus l'activité est élévée, exponentiel selon la moyenne + # - derns entre 4 et 6 (pourrait être adapté selon les stats d'activité) + # + # WIP Badges: + # - derns + # - streaks + # - faciles + # - ? + + require Logger + + @perfects [~r/preum(s|)/i] + + # dets {{chan, day = {yyyy, mm, dd}}, nick, now, perfect?, text} + + def all(dets) do + :dets.foldl(fn(i, acc) -> [i|acc] end, [], dets) + end + + def all(dets, channel) do + fun = fn({{chan, date}, account_id, time, perfect, text}, acc) -> + if channel == chan do + [%{date: date, account_id: account_id, time: time, perfect: perfect, text: text} | acc] + else + acc + end + end + :dets.foldl(fun, [], dets) + end + + def topnicks(dets, channel, options \\ []) do + sort_elem = case Keyword.get(options, :sort_by, :score) do + :score -> 1 + :count -> 0 + end + + fun = fn(x = {{chan, date}, account_id, time, perfect, text}, acc) -> + if (channel == nil and chan) or (channel == chan) do + {count, points} = Map.get(acc, account_id, {0, 0}) + score = score(chan, account_id, time, perfect, text) + Map.put(acc, account_id, {count + 1, points + score}) + else + acc + end + end + :dets.foldl(fun, %{}, dets) + |> Enum.sort_by(fn({_account_id, value}) -> elem(value, sort_elem) end, &>=/2) + end + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def dets do + (Nola.data_path() <> "/preums.dets") |> String.to_charlist() + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, dets} = :dets.open_file(dets(), [{:repair, :force}]) + Util.ets_mutate_select_each(:dets, dets, [{:"$1", [], [:"$1"]}], fn(table, obj) -> + {key, nick, now, perfect, text} = obj + case key do + {{net, {bork,chan}}, date} -> + :dets.delete(table, key) + nick = if IRC.Account.get(nick) do + nick + else + if acct = IRC.Account.find_always_by_nick(net, nil, nick) do + acct.id + else + nick + end + end + :dets.insert(table, { { {net,chan}, date }, nick, now, perfect, text}) + {{_net, nil}, _} -> + :dets.delete(table, key) + {{net, chan}, date} -> + if !IRC.Account.get(nick) do + if acct = IRC.Account.find_always_by_nick(net, chan, nick) do + :dets.delete(table, key) + :dets.insert(table, { { {net,chan}, date }, acct.id, now, perfect, text}) + end + end + _ -> + Logger.debug("DID NOT FIX: #{inspect key}") + end + end) + {:ok, %{dets: dets}} + end + + # Latest + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do + channelkey = {m.network, m.channel} + state = handle_preums(m, state) + tz = timezone(channelkey) + {:ok, now} = DateTime.now(tz, Tzdata.TimeZoneDatabase) + date = {now.year, now.month, now.day} + key = {channelkey, date} + chan_cache = Map.get(state, channelkey, %{}) + item = if i = Map.get(chan_cache, date) do + i + else + case :dets.lookup(state.dets, key) do + [item = {^key, _account_id, _now, _perfect, _text}] -> item + _ -> nil + end + end + + if item do + {_, account_id, date, _perfect, text} = item + h = "#{date.hour}:#{date.minute}:#{date.second}" + account = IRC.Account.get(account_id) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + m.replyfun.("preums: #{nick} à #{h}: “#{text}”") + end + {:noreply, state} + end + + # Stats + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :dot}}}, state) do + channel = {m.network, m.channel} + state = handle_preums(m, state) + top = topnicks(state.dets, channel, sort_by: :score) + |> Enum.map(fn({account_id, {count, score}}) -> + account = IRC.Account.get(account_id) + user = IRC.UserTrack.find_by_account(m.network, account) + nick = if(user, do: user.nick, else: account.name) + "#{nick}: #{score} (#{count})" + end) + |> Enum.intersperse(", ") + |> Enum.join("") + msg = unless top == "" do + "top preums: #{top}" + else + "vous êtes tous nuls" + end + m.replyfun.(msg) + {:noreply, state} + end + + # Help + def handle_info({:irc, :trigger, "preums", m = %IRC.Message{trigger: %IRC.Trigger{type: :query}}}, state) do + state = handle_preums(m, state) + msg = "!preums - preums du jour, .preums top preumseurs" + m.replymsg.(msg) + {: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 + + # Account + def handle_info({:account_change, old_id, new_id}, state) do + spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + rename_object_owner(table, obj, new_id) + end) + {:noreply, state} + end + + # Account: move from nick to account id + # FIXME: Doesn't seem to work. + def handle_info({:accounts, accounts}, state) do + for x={:account, _net, _chan, _nick, _account_id} <- accounts do + handle_info(x, state) + end + {:noreply, state} + end + def handle_info({:account, _net, _chan, nick, account_id}, state) do + nick = String.downcase(nick) + spec = [{{:_, :"$1", :_, :_, :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + Logger.debug("account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, obj, account_id) + end) + {:noreply, state} + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + + defp rename_object_owner(table, object = {key, _, now, perfect, time}, new_id) do + :dets.delete_object(table, key) + :dets.insert(table, {key, new_id, now, perfect, time}) + end + + defp timezone(channel) do + env = Application.get_env(:nola, Nola.Plugins.Preums, []) + 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(%IRC.Message{channel: nil}, state) do + state + end + + defp handle_preums(m = %IRC.Message{text: text, sender: sender}, state) do + channel = {m.network, m.channel} + 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 + Map.put(state, channel, %{date => item}) + [] -> + # Preums won! + perfect? = Enum.any?(@perfects, fn(perfect) -> Regex.match?(perfect, text) end) + item = {key, m.account.id, now, perfect?, text} + :dets.insert(state.dets, item) + :dets.sync(state.dets) + Map.put(state, channel, %{date => item}) + {:error, _} = error -> + Logger.error("#{__MODULE__} dets lookup failed: #{inspect error}") + state + end + else + state + end + end + + def score(_chan, _account, _time, _perfect, _text) do + 1 + end + + +end diff --git a/lib/plugins/quatre_cent_vingt.ex b/lib/plugins/quatre_cent_vingt.ex new file mode 100644 index 0000000..3331625 --- /dev/null +++ b/lib/plugins/quatre_cent_vingt.ex @@ -0,0 +1,149 @@ +defmodule Nola.Plugins.QuatreCentVingt do + require Logger + + @moduledoc """ + # 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. + """ + + @achievements %{ + 1 => ["[le premier… il faut bien commencer un jour]"], + 10 => ["T'en es seulement à 10 ? ╭∩╮(Ο_Ο)╭∩╮"], + 42 => ["Bravo, et est-ce que autant de pétards t'on aidés à trouver la Réponse ? ٩(- ̮̮̃-̃)۶ [42]"], + 100 => ["°º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸ 100 °º¤ø,¸¸,ø¤º°`°º¤ø,¸,ø¤°º¤ø,¸¸,ø¤º°`°º¤ø,¸"], + 115 => [" ۜ\(סּںסּَ` )/ۜ 115!!"] + } + + @emojis [ + "\\o/", + "~o~", + "~~o∞~~", + "*\\o/*", + "**\\o/**", + "*ô*", + ] + + @coeffs Range.new(1, 100) + + def irc_doc, do: @moduledoc + + def start_link, do: GenServer.start_link(__MODULE__, [], name: __MODULE__) + + def init(_) do + for coeff <- @coeffs do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{420*coeff}", [plugin: __MODULE__]) + end + {:ok, _} = Registry.register(IRC.PubSub, "account", [plugin: __MODULE__]) + dets_filename = (Nola.data_path() <> "/420.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag},{:repair,:force}]) + {:ok, dets} + :ignore + end + + for coeff <- @coeffs do + qvc = to_string(420 * coeff) + def handle_info({:irc, :trigger, unquote(qvc), m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :bang}}}, dets) do + {count, last} = get_statistics_for_nick(dets, m.account.id) + count = count + unquote(coeff) + text = achievement_text(count) + now = DateTime.to_unix(DateTime.utc_now())-1 # this is ugly + for i <- Range.new(1, unquote(coeff)) do + :ok = :dets.insert(dets, {m.account.id, now+i}) + end + last_s = if last do + last_s = format_relative_timestamp(last) + " (le dernier était #{last_s})" + else + "" + end + m.replyfun.("#{m.sender.nick} 420 +#{unquote(coeff)} #{text}#{last_s}") + {:noreply, dets} + end + end + + def handle_info({:irc, :trigger, "420", m = %IRC.Message{trigger: %IRC.Trigger{args: [nick], type: :bang}}}, dets) do + account = IRC.Account.find_by_nick(m.network, nick) + if account do + text = case get_statistics_for_nick(dets, m.account.id) do + {0, _} -> "#{nick} n'a jamais !420 ... honte à lui." + {count, last} -> + last_s = format_relative_timestamp(last) + "#{nick} 420: total #{count}, le dernier #{last_s}" + end + m.replyfun.(text) + else + m.replyfun.("je connais pas de #{nick}") + end + {:noreply, dets} + end + + # Account + def handle_info({:account_change, old_id, new_id}, dets) do + spec = [{{:"$1", :_}, [{:==, :"$1", {:const, old_id}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> + rename_object_owner(table, obj, new_id) + end) + {:noreply, dets} + end + + # Account: move from nick to account id + def handle_info({:accounts, accounts}, dets) do + for x={:account, _net, _chan, _nick, _account_id} <- accounts do + handle_info(x, dets) + end + {:noreply, dets} + end + def handle_info({:account, _net, _chan, nick, account_id}, dets) do + nick = String.downcase(nick) + spec = [{{:"$1", :_}, [{:==, :"$1", {:const, nick}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, dets, spec, fn(table, obj) -> + Logger.debug("account:: merging #{nick} -> #{account_id}") + rename_object_owner(table, obj, account_id) + end) + {:noreply, dets} + end + + def handle_info(_, dets) do + {:noreply, dets} + end + + defp rename_object_owner(table, object = {_, at}, account_id) do + :dets.delete_object(table, object) + :dets.insert(table, {account_id, at}) + end + + + defp format_relative_timestamp(timestamp) do + alias Timex.Format.DateTime.Formatters + alias Timex.Timezone + date = timestamp + |> DateTime.from_unix! + |> 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 + + defp get_statistics_for_nick(dets, acct) do + qvc = :dets.lookup(dets, acct) |> Enum.sort + count = Enum.reduce(qvc, 0, fn(_, acc) -> acc + 1 end) + {_, last} = List.last(qvc) || {nil, nil} + {count, last} + end + + @achievements_keys Map.keys(@achievements) + defp achievement_text(count) when count in @achievements_keys do + Enum.random(Map.get(@achievements, count)) + end + + defp achievement_text(count) do + emoji = Enum.random(@emojis) + "#{emoji} [#{count}]" + end + +end diff --git a/lib/plugins/radio_france.ex b/lib/plugins/radio_france.ex new file mode 100644 index 0000000..2096de8 --- /dev/null +++ b/lib/plugins/radio_france.ex @@ -0,0 +1,133 @@ +defmodule Nola.Plugins.RadioFrance do + require Logger + + def irc_doc() do + """ + # radio france + + Qu'est ce qu'on écoute sur radio france ? + + * **!radiofrance `[station]`, !rf `[station]`** + * **!fip, !inter, !info, !bleu, !culture, !musique, !fip `[sous-station]`, !bleu `[région]`** + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + @trigger "radiofrance" + @shortcuts ~w(fip inter info bleu culture musique) + + def init(_) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:radiofrance", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:rf", regopts) + for s <- @shortcuts do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{s}", regopts) + end + {:ok, nil} + end + + def handle_info({:irc, :trigger, "rf", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang}}}, state) do + handle_info({:irc, :trigger, "radiofrance", m}, state) + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: []}}}, state) do + m.replyfun.("radiofrance: précisez la station!") + {:noreply, state} + end + + def handle_info({:irc, :trigger, @trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) do + now(args_to_station(args), m) + {:noreply, state} + end + + def handle_info({:irc, :trigger, trigger, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: args}}}, state) when trigger in @shortcuts do + now(args_to_station([trigger | args]), m) + {:noreply, state} + end + + defp args_to_station(args) do + args + |> Enum.map(&unalias/1) + |> Enum.map(&String.downcase/1) + |> Enum.join("_") + end + + def handle_info(info, state) do + Logger.debug("unhandled info: #{inspect info}") + {:noreply, state} + end + + defp now(station, m) when is_binary(station) do + case HTTPoison.get(np_url(station), [], []) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + json = Poison.decode!(body) + song? = !!get_in(json, ["now", "song"]) + station = reformat_station_name(get_in(json, ["now", "stationName"])) + now_title = get_in(json, ["now", "firstLine", "title"]) + now_subtitle = get_in(json, ["now", "secondLine", "title"]) + next_title = get_in(json, ["next", "firstLine", "title"]) + next_subtitle = get_in(json, ["next", "secondLine", "title"]) + next_song? = !!get_in(json, ["next", "song"]) + next_at = get_in(json, ["next", "startTime"]) + + now = format_title(song?, now_title, now_subtitle) + prefix = if song?, do: "🎶", else: "🎤" + m.replyfun.("#{prefix} #{station}: #{now}") + + next = format_title(song?, next_title, next_subtitle) + if next do + next_prefix = if next_at do + next_date = DateTime.from_unix!(next_at) + in_seconds = DateTime.diff(next_date, DateTime.utc_now()) + in_minutes = ceil(in_seconds / 60) + if in_minutes >= 5 do + if next_song?, do: "#{in_minutes}m 🔜", else: "dans #{in_minutes} minutes:" + else + if next_song?, do: "🔜", else: "suivi de:" + end + else + if next_song?, do: "🔜", else: "à suivre:" + end + m.replyfun.("#{next_prefix} #{next}") + end + + {:error, %HTTPoison.Response{status_code: 404}} -> + m.replyfun.("radiofrance: la radio \"#{station}\" n'existe pas") + + {:error, %HTTPoison.Response{status_code: code}} -> + m.replyfun.("radiofrance: erreur http #{code}") + + _ -> + m.replyfun.("radiofrance: ça n'a pas marché, rip") + end + end + + defp np_url(station), do: "https://www.radiofrance.fr/api/v2.0/stations/#{station}/live" + + defp unalias("inter"), do: "franceinter" + defp unalias("info"), do: "franceinfo" + defp unalias("bleu"), do: "francebleu" + defp unalias("culture"), do: "franceculture" + defp unalias("musique"), do: "francemusique" + defp unalias(station), do: station + + defp format_title(_, nil, nil) do + nil + end + defp format_title(true, title, artist) do + [artist, title] |> Enum.filter(& &1) |> Enum.join(" - ") + end + defp format_title(false, show, section) do + [show, section] |> Enum.filter(& &1) |> Enum.join(": ") + end + + defp reformat_station_name(station) do + station + |> String.replace("france", "france ") + |> String.replace("_", " ") + end + +end diff --git a/lib/plugins/say.ex b/lib/plugins/say.ex new file mode 100644 index 0000000..91ffacc --- /dev/null +++ b/lib/plugins/say.ex @@ -0,0 +1,73 @@ +defmodule Nola.Plugins.Say do + + def irc_doc do + """ + # say + + Say something... + + * **!say `<channel>` `<text>`** say something on `channel` + * **!asay `<channel>` `<text>`** same but anonymously + + You must be a member of the channel. + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [type: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) + {:ok, nil} + end + + def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + text = Enum.join(text, " ") + say_for(m.account, target, text, true) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do + text = Enum.join(text, " ") + say_for(m.account, target, text, false) + {:noreply, state} + end + + def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do + case String.split(rest, " ", parts: 2) do + [target, text] -> say_for(m.account, target, text, true) + _ -> nil + end + {:noreply, state} + end + + def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do + case String.split(rest, " ", parts: 2) do + [target, text] -> say_for(m.account, target, text, false) + _ -> nil + end + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + + defp say_for(account, target, text, with_nick?) do + for {net, chan} <- IRC.Membership.of_account(account) do + chan2 = String.replace(chan, "#", "") + if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do + if with_nick? do + IRC.send_message_as(account, net, chan, text) + else + IRC.Connection.broadcast_message(net, chan, text) + end + end + end + end + +end diff --git a/lib/plugins/script.ex b/lib/plugins/script.ex new file mode 100644 index 0000000..d383abb --- /dev/null +++ b/lib/plugins/script.ex @@ -0,0 +1,42 @@ +defmodule Nola.Plugins.Script do + require Logger + + @moduledoc """ + Allows to run outside scripts. Scripts are expected to be long running and receive/send data as JSON over stdin/stdout. + + """ + + @ircdoc """ + # script + + Allows to run an outside script. + + * **+script `<name>` `[command]`** défini/lance un script + * **-script `<name>`** arrête un script + * **-script del `<name>`** supprime un script + """ + + def irc_doc, do: @ircdoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:script", [plugin: __MODULE__]) + dets_filename = (Nola.data_path() <> "/" <> "scripts.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %{dets: dets}} + end + + def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :plus, args: [name | args]}}}, state) do + end + + def handle_info({:irc, :trigger, "script", m = %{trigger: %{type: :minus, args: args}}}, state) do + case args do + ["del", name] -> :ok #prout + [name] -> :ok#stop + end + end + +end diff --git a/lib/plugins/seen.ex b/lib/plugins/seen.ex new file mode 100644 index 0000000..65af0c1 --- /dev/null +++ b/lib/plugins/seen.ex @@ -0,0 +1,59 @@ +defmodule Nola.Plugins.Seen do + @moduledoc """ + # seen + + * **!seen `<nick>`** + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) + dets_filename = (Nola.data_path() <> "/seen.dets") |> String.to_charlist() + {:ok, dets} = :dets.open_file(dets_filename, []) + {:ok, %{dets: dets}} + end + + def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do + witness(m, state) + m.replyfun.(last_seen(m.channel, nick, state)) + {:noreply, state} + end + + def handle_info({:irc, :trigger, _, m}, state) do + witness(m, state) + {:noreply, state} + end + + def handle_info({:irc, :text, m}, state) do + witness(m, state) + {:noreply, state} + end + + defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do + :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) + :ok + end + + defp last_seen(channel, nick, %{dets: dets}) do + case :dets.lookup(dets, {channel, nick}) do + [{_, date, text}] -> + diff = round(DateTime.diff(DateTime.utc_now(), date)/60) + cond do + diff >= 30 -> + duration = Timex.Duration.from_minutes(diff) + format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) + "#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”" + true -> "#{nick} est là..." + end + [] -> + "je ne connais pas de #{nick}" + end + end + +end diff --git a/lib/plugins/sms.ex b/lib/plugins/sms.ex new file mode 100644 index 0000000..ad16f78 --- /dev/null +++ b/lib/plugins/sms.ex @@ -0,0 +1,165 @@ +defmodule Nola.Plugins.Sms do + @moduledoc """ + ## sms + + * **!sms `<nick>` `<message>`** envoie un SMS. + """ + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + require Logger + + def incoming(from, "enable "<>key) do + key = String.trim(key) + account = IRC.Account.find_meta_account("sms-validation-code", String.downcase(key)) + if account do + net = IRC.Account.get_meta(account, "sms-validation-target") + IRC.Account.put_meta(account, "sms-number", from) + IRC.Account.delete_meta(account, "sms-validation-code") + IRC.Account.delete_meta(account, "sms-validation-number") + IRC.Account.delete_meta(account, "sms-validation-target") + IRC.Connection.broadcast_message(net, account, "SMS Number #{from} added!") + send_sms(from, "Yay! Number linked to account #{account.name}") + end + end + + def incoming(from, message) do + account = IRC.Account.find_meta_account("sms-number", from) + if account do + reply_fun = fn(text) -> + send_sms(from, text) + end + trigger_text = if Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(message, trigger) end) do + message + else + "!"<>message + end + message = %IRC.Message{ + id: FlakeId.get(), + transport: :sms, + network: "sms", + channel: nil, + text: message, + account: account, + sender: %ExIRC.SenderInfo{nick: account.name}, + replyfun: reply_fun, + trigger: IRC.Connection.extract_trigger(trigger_text) + } + Logger.debug("converted sms to message: #{inspect message}") + IRC.Connection.publish(message, ["messages:sms"]) + message + end + end + + def my_number() do + Keyword.get(Application.get_env(:nola, :sms, []), :number, "+33000000000") + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def path() do + account = Keyword.get(Application.get_env(:nola, :sms), :account) + "https://eu.api.ovh.com/1.0/sms/#{account}" + end + + def path(rest) do + Path.join(path(), rest) + end + + def send_sms(number, text) do + url = path("/virtualNumbers/#{my_number()}/jobs") + body = %{ + "message" => text, + "receivers" => [number], + #"senderForResponse" => true, + #"noStopClause" => true, + "charset" => "UTF-8", + "coding" => "8bit" + } |> Poison.encode!() + headers = [{"content-type", "application/json"}] ++ sign("POST", url, body) + options = [] + case HTTPoison.post(url, body, headers, options) do + {:ok, %HTTPoison.Response{status_code: 200}} -> :ok + {:ok, %HTTPoison.Response{status_code: code} = resp} -> + Logger.error("SMS Error: #{inspect resp}") + {:error, code} + {:error, error} -> {:error, error} + end + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:sms", [plugin: __MODULE__]) + :ok = register_ovh_callback() + {:ok, %{}} + :ignore + end + + def handle_info({:irc, :trigger, "sms", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick | text]}}}, state) do + with \ + {:tree, false} <- {:tree, m.sender.nick == "Tree"}, + {_, %IRC.Account{} = account} <- {:account, IRC.Account.find_always_by_nick(m.network, m.channel, nick)}, + {_, number} when not is_nil(number) <- {:number, IRC.Account.get_meta(account, "sms-number")} + do + text = Enum.join(text, " ") + sender = if m.channel do + "#{m.channel} <#{m.sender.nick}> " + else + "<#{m.sender.nick}> " + end + case send_sms(number, sender<>text) do + :ok -> m.replyfun.("sent!") + {:error, error} -> m.replyfun.("not sent, error: #{inspect error}") + end + else + {:tree, _} -> m.replyfun.("Tree: va en enfer") + {:account, _} -> m.replyfun.("#{nick} not known") + {:number, _} -> m.replyfun.("#{nick} have not enabled sms") + end + {:noreply, state} + end + + def handle_info(msg, state) do + {:noreply, state} + end + + defp register_ovh_callback() do + url = path() + body = %{ + "callBack" =>NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), + "smsResponse" => %{ + "cgiUrl" => NolaWeb.Router.Helpers.sms_url(NolaWeb.Endpoint, :ovh_callback), + "responseType" => "cgi" + } + } |> Poison.encode!() + headers = [{"content-type", "application/json"}] ++ sign("PUT", url, body) + options = [] + case HTTPoison.put(url, body, headers, options) do + {:ok, %HTTPoison.Response{status_code: 200}} -> + :ok + error -> error + end + end + + defp sign(method, url, body) do + ts = DateTime.utc_now() |> DateTime.to_unix() + as = env(:app_secret) + ck = env(:consumer_key) + sign = Enum.join([as, ck, String.upcase(method), url, body, ts], "+") + sign_hex = :crypto.hash(:sha, sign) |> Base.encode16(case: :lower) + headers = [{"X-OVH-Application", env(:app_key)}, {"X-OVH-Timestamp", ts}, + {"X-OVH-Signature", "$1$"<>sign_hex}, {"X-Ovh-Consumer", ck}] + end + + def parse_number(num) do + {:error, :todo} + end + + defp env() do + Application.get_env(:nola, :sms) + end + + defp env(key) do + Keyword.get(env(), key) + end +end diff --git a/lib/plugins/tell.ex b/lib/plugins/tell.ex new file mode 100644 index 0000000..8978c82 --- /dev/null +++ b/lib/plugins/tell.ex @@ -0,0 +1,106 @@ +defmodule Nola.Plugins.Tell do + use GenServer + + @moduledoc """ + # Tell + + * **!tell `<nick>` `<message>`**: tell `message` to `nick` when they reconnect. + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def dets do + (Nola.data_path() <> "/tell.dets") |> String.to_charlist() + end + + def tell(m, target, message) do + GenServer.cast(__MODULE__, {:tell, m, target, message}) + end + + def init([]) do + regopts = [plugin: __MODULE__] + {:ok, _} = Registry.register(IRC.PubSub, "account", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:tell", regopts) + {:ok, dets} = :dets.open_file(dets(), [type: :bag]) + {:ok, %{dets: dets}} + end + + def handle_cast({:tell, m, target, message}, state) do + do_tell(state, m, target, message) + {:noreply, state} + end + + def handle_info({:irc, :trigger, "tell", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [target | message]}}}, state) do + do_tell(state, m, target, message) + {:noreply, state} + end + + def handle_info({:account, network, channel, nick, account_id}, state) do + messages = :dets.lookup(state.dets, {network, channel, account_id}) + if messages != [] do + strs = Enum.map(messages, fn({_, from, message, at}) -> + account = IRC.Account.get(from) + user = IRC.UserTrack.find_by_account(network, account) + fromnick = if user, do: user.nick, else: account.name + "#{nick}: <#{fromnick}> #{message}" + end) + Enum.each(strs, fn(s) -> IRC.Connection.broadcast_message(network, channel, s) end) + :dets.delete(state.dets, {network, channel, account_id}) + end + {:noreply, state} + end + + def handle_info({:account_change, old_id, new_id}, state) do + #:ets.fun2ms(fn({ {_net, _chan, target_id}, from_id, _, _} = obj) when (target_id == old_id) or (from_id == old_id) -> obj end) + spec = [{{{:"$1", :"$2", :"$3"}, :"$4", :_, :_}, [{:orelse, {:==, :"$3", {:const, old_id}}, {:==, :"$4", {:const, old_id}}}], [:"$_"]}] + Util.ets_mutate_select_each(:dets, state.dets, spec, fn(table, obj) -> + case obj do + { {net, chan, ^old_id}, from_id, message, at } = obj -> + :dets.delete(obj) + :dets.insert(table, {{net, chan, new_id}, from_id, message, at}) + {key, ^old_id, message, at} = obj -> + :dets.delete(table, obj) + :dets.insert(table, {key, new_id, message, at}) + _ -> :ok + end + end) + {:noreply, state} + end + + + def handle_info(info, state) do + {:noreply, state} + end + + def terminate(_, state) do + :dets.close(state.dets) + :ok + end + + defp do_tell(state, m, nick_target, message) do + target = IRC.Account.find_always_by_nick(m.network, m.channel, nick_target) + message = Enum.join(message, " ") + with \ + {:target, %IRC.Account{} = target} <- {:target, target}, + {:same, false} <- {:same, target.id == m.account.id}, + target_user = IRC.UserTrack.find_by_account(m.network, target), + target_nick = if(target_user, do: target_user.nick, else: target.name), + present? = if(target_user, do: Map.has_key?(target_user.last_active, m.channel)), + {:absent, true, _} <- {:absent, !present?, target_nick}, + {:message, message} <- {:message, message} + do + obj = { {m.network, m.channel, target.id}, m.account.id, message, NaiveDateTime.utc_now()} + :dets.insert(state.dets, obj) + m.replyfun.("will tell to #{target_nick}") + else + {:same, _} -> m.replyfun.("are you so stupid that you need a bot to tell yourself things ?") + {:target, _} -> m.replyfun.("#{nick_target} unknown") + {:absent, _, nick} -> m.replyfun.("#{nick} is here, tell yourself!") + {:message, _} -> m.replyfun.("can't tell without a message") + end + end + +end diff --git a/lib/plugins/txt.ex b/lib/plugins/txt.ex new file mode 100644 index 0000000..f6e71a3 --- /dev/null +++ b/lib/plugins/txt.ex @@ -0,0 +1,556 @@ +defmodule Nola.Plugins.Txt 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.Plugins.Txt.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 diff --git a/lib/plugins/txt/markov.ex b/lib/plugins/txt/markov.ex new file mode 100644 index 0000000..b47666c --- /dev/null +++ b/lib/plugins/txt/markov.ex @@ -0,0 +1,9 @@ +defmodule Nola.Plugins.Txt.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/plugins/txt/markov_native.ex b/lib/plugins/txt/markov_native.ex new file mode 100644 index 0000000..aa6b454 --- /dev/null +++ b/lib/plugins/txt/markov_native.ex @@ -0,0 +1,33 @@ +defmodule Nola.Plugins.Txt.MarkovNative do + @behaviour Nola.Plugins.Txt.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/plugins/txt/markov_py_markovify.ex b/lib/plugins/txt/markov_py_markovify.ex new file mode 100644 index 0000000..f79ed47 --- /dev/null +++ b/lib/plugins/txt/markov_py_markovify.ex @@ -0,0 +1,39 @@ +defmodule Nola.Plugins.Txt.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(Nola.Plugins.Txt.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(:nola)) <> "/irc/txt/markovify.py" + env = Application.get_env(:nola, Nola.Plugins.Txt, []) + |> Keyword.get(:py_markovify, []) + + {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} + end + + + +end diff --git a/lib/plugins/untappd.ex b/lib/plugins/untappd.ex new file mode 100644 index 0000000..cf5c694 --- /dev/null +++ b/lib/plugins/untappd.ex @@ -0,0 +1,66 @@ +defmodule Nola.Plugins.Untappd do + + def irc_doc() do + """ + # [Untappd](https://untappd.com) + + * `!beer <beer name>` Information about the first beer matching `<beer name>` + * `?beer <beer name>` List the 10 firsts beer matching `<beer name>` + + _Note_: The best way to search is always "Brewery Name + Beer Name", such as "Dogfish 60 Minute". + + Link your Untappd account to the bot (for automated checkins on [alcoolog](#alcoolog), ...) with the `enable-untappd` command, in private. + """ + end + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:beer", [plugin: __MODULE__]) + {:ok, %{}} + end + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) do + case Untappd.search_beer(Enum.join(args, " "), limit: 1) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => [result | _]}}}} -> + %{"beer" => beer, "brewery" => brewery} = result + description = Map.get(beer, "beer_description") + |> String.replace("\n", " ") + |> String.replace("\r", " ") + |> String.trim() + beer_s = "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" + city = get_in(brewery, ["location", "brewery_city"]) + location = [Map.get(brewery, "brewery_type"), city, Map.get(brewery, "country_name")] + |> Enum.filter(fn(x) -> x end) + |> Enum.join(", ") + extra = "#{Map.get(beer, "beer_style")} - IBU: #{Map.get(beer, "beer_ibu")} - #{location}" + m.replyfun.([beer_s, extra, description]) + err -> + m.replyfun.("Error") + end + {:noreply, state} + end + + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %{type: :query, args: args}}}, state) do + case Untappd.search_beer(Enum.join(args, " ")) do + {:ok, %{"response" => %{"beers" => %{"count" => count, "items" => results}}}} -> + beers = for %{"beer" => beer, "brewery" => brewery} <- results do + "#{Map.get(brewery, "brewery_name")}: #{Map.get(beer, "beer_name")} - #{Map.get(beer, "beer_abv")}°" + end + |> Enum.intersperse(", ") + |> Enum.join("") + m.replyfun.("#{count}. #{beers}") + err -> + m.replyfun.("Error") + end + {:noreply, state} + end + + def handle_info(info, state) do + {:noreply, state} + end + +end diff --git a/lib/plugins/user_mention.ex b/lib/plugins/user_mention.ex new file mode 100644 index 0000000..c4dd719 --- /dev/null +++ b/lib/plugins/user_mention.ex @@ -0,0 +1,52 @@ +defmodule Nola.Plugins.UserMention do + @moduledoc """ + # mention + + * **@`<nick>` `<message>`**: notifie si possible le nick immédiatement via Telegram, SMS, ou équivalent à `!tell`. + """ + + require Logger + + def short_irc_doc, do: false + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "triggers", plugin: __MODULE__) + {:ok, nil} + end + + def handle_info({:irc, :trigger, nick, message = %IRC.Message{sender: sender, account: account, network: network, channel: channel, trigger: %IRC.Trigger{type: :at, args: content}}}, state) do + nick = nick + |> String.trim(":") + |> String.trim(",") + target = IRC.Account.find_always_by_nick(network, channel, nick) + if target do + telegram = IRC.Account.get_meta(target, "telegram-id") + sms = IRC.Account.get_meta(target, "sms-number") + text = "#{channel} <#{sender.nick}> #{Enum.join(content, " ")}" + + cond do + telegram -> + Nola.Telegram.send_message(telegram, "`#{channel}` <**#{sender.nick}**> #{Enum.join(content, " ")}") + sms -> + case Nola.Plugins.Sms.send_sms(sms, text) do + {:error, code} -> message.replyfun("#{sender.nick}: erreur #{code} (sms)") + end + true -> + Nola.Plugins.Tell.tell(message, nick, content) + end + else + message.replyfun.("#{nick} m'est inconnu") + end + {:noreply, state} + end + + def handle_info(_, state) do + {:noreply, state} + end + +end diff --git a/lib/plugins/wikipedia.ex b/lib/plugins/wikipedia.ex new file mode 100644 index 0000000..dcd1874 --- /dev/null +++ b/lib/plugins/wikipedia.ex @@ -0,0 +1,90 @@ +defmodule Nola.Plugins.Wikipedia do + require Logger + + @moduledoc """ + # wikipédia + + * **!wp `<recherche>`**: retourne le premier résultat de la `<recherche>` Wikipedia + * **!wp**: un article Wikipédia au hasard + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:wp", [plugin: __MODULE__]) + {:ok, nil} + end + + 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, _, 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("", message), do: irc_random(message) + defp irc_search(query, message) do + params = %{ + "action" => "query", + "list" => "search", + "srsearch" => String.strip(query), + "srlimit" => 1, + } + case query_wikipedia(params) do + {:ok, %{"query" => %{"search" => [item | _]}}} -> + title = item["title"] + url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") + msg = "Wikipédia: #{title} — #{url}" + message.replyfun.(msg) + _ -> + nil + end + end + + defp irc_random(message) do + params = %{ + "action" => "query", + "generator" => "random", + "grnnamespace" => 0, + "prop" => "info" + } + case query_wikipedia(params) do + {:ok, %{"query" => %{"pages" => map = %{}}}} -> + [{_, item}] = Map.to_list(map) + title = item["title"] + url = "https://fr.wikipedia.org/wiki/" <> String.replace(title, " ", "_") + msg = "Wikipédia: #{title} — #{url}" + message.replyfun.(msg) + _ -> + nil + end + end + + defp query_wikipedia(params) do + url = "https://fr.wikipedia.org/w/api.php" + params = params + |> Map.put("format", "json") + |> Map.put("utf8", "") + + 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 "Wikipedia HTTP 400: #{inspect body}" + {:error, "http 400"} + error -> + Logger.error "Wikipedia http error: #{inspect error}" + {:error, "http client error"} + end + end + +end diff --git a/lib/plugins/wolfram_alpha.ex b/lib/plugins/wolfram_alpha.ex new file mode 100644 index 0000000..c2f106e --- /dev/null +++ b/lib/plugins/wolfram_alpha.ex @@ -0,0 +1,47 @@ +defmodule Nola.Plugins.WolframAlpha do + use GenServer + require Logger + + @moduledoc """ + # wolfram alpha + + * **`!wa <requête>`** lance `<requête>` sur WolframAlpha + """ + + def irc_doc, do: @moduledoc + + def start_link() do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", [plugin: __MODULE__]) + {:ok, nil} + end + + def handle_info({:irc, :trigger, _, m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: query}}}, state) do + query = Enum.join(query, " ") + params = %{ + "appid" => Keyword.get(Application.get_env(:nola, :wolframalpha, []), :app_id, "NO_APP_ID"), + "units" => "metric", + "i" => query + } + url = "https://www.wolframalpha.com/input/?i=" <> URI.encode(query) + case HTTPoison.get("http://api.wolframalpha.com/v1/result", [], [params: params]) do + {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> + m.replyfun.(["#{query} -> #{body}", url]) + {:ok, %HTTPoison.Response{status_code: code, body: body}} -> + error = case {code, body} do + {501, b} -> "input invalide: #{body}" + {code, error} -> "erreur #{code}: #{body || ""}" + end + m.replyfun.("wa: #{error}") + {:error, %HTTPoison.Error{reason: reason}} -> + m.replyfun.("wa: erreur http: #{to_string(reason)}") + _ -> + m.replyfun.("wa: erreur http") + end + {:noreply, state} + end + +end diff --git a/lib/plugins/youtube.ex b/lib/plugins/youtube.ex new file mode 100644 index 0000000..c62f73c --- /dev/null +++ b/lib/plugins/youtube.ex @@ -0,0 +1,104 @@ +defmodule Nola.Plugins.YouTube 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__, [], name: __MODULE__) + end + + def init([]) do + for t <- ["trigger:yt", "trigger:youtube"], do: {:ok, _} = Registry.register(IRC.PubSub, t, [plugin: __MODULE__]) + {: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(:nola, :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 |