diff options
Diffstat (limited to '')
-rw-r--r-- | lib/lsg_irc.ex | 4 | ||||
-rw-r--r-- | lib/lsg_irc/alcolog_plugin.ex | 898 | ||||
-rw-r--r-- | lib/lsg_irc/alcoolog_announcer_plugin.ex | 198 | ||||
-rw-r--r-- | lib/lsg_irc/bourosama_plugin.ex | 57 | ||||
-rw-r--r-- | lib/lsg_irc/coronavirus_plugin.ex | 116 | ||||
-rw-r--r-- | lib/lsg_irc/correction_plugin.ex | 46 | ||||
-rw-r--r-- | lib/lsg_irc/link_plugin.ex | 97 | ||||
-rw-r--r-- | lib/lsg_irc/link_plugin/imgur.ex | 8 | ||||
-rw-r--r-- | lib/lsg_irc/seen_plugin.ex | 58 | ||||
-rw-r--r-- | lib/lsg_irc/txt_plugin.ex | 39 | ||||
-rw-r--r-- | lib/lsg_irc/wolfram_alpha_plugin.ex | 47 |
11 files changed, 1411 insertions, 157 deletions
diff --git a/lib/lsg_irc.ex b/lib/lsg_irc.ex index 482cb5d..cb1e382 100644 --- a/lib/lsg_irc.ex +++ b/lib/lsg_irc.ex @@ -19,7 +19,11 @@ defmodule LSG.IRC do for plugin <- Application.get_env(:lsg, :irc)[:plugins] do worker(plugin, [], [name: plugin]) end + ++ [ + worker(LSG.IRC.AlcoologAnnouncerPlugin, []) + ] end + end diff --git a/lib/lsg_irc/alcolog_plugin.ex b/lib/lsg_irc/alcolog_plugin.ex index b5392a3..f27dcd8 100644 --- a/lib/lsg_irc/alcolog_plugin.ex +++ b/lib/lsg_irc/alcolog_plugin.ex @@ -1,13 +1,27 @@ -defmodule LSG.IRC.AlcologPlugin do +defmodule LSG.IRC.AlcoologPlugin do require Logger @moduledoc """ - # alcoolisme _(v2)_ + # alcoolisme - * **`!santai <montant> <degrés d'alcool> [annotation]`** enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **`!santai <montant> <degrés d'alcool> [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. + * **-santai**: annule la dernière entrée d'alcoolisme. + * **.alcoolisme**: état du channel en temps réel. + * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. + * **!alcoolisme `semaine`**: affiche le top des 7 derniers jours + * **+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**: 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 ? - * **!`<trigger>` `[coeff]` `[annotation]`** enregistre de l'alcoolisme. - * **!alcoolisme `[pseudo]`** affiche les points d'alcoolisme. + 1 point = 1 volume d'alcool + + Anciennes commandes: + + * **!`<trigger>` `[coeff]` `[annotation]`**: enregistre de l'alcoolisme. Triggers/Coeffs: @@ -23,13 +37,14 @@ defmodule LSG.IRC.AlcologPlugin do @triggers %{ "apero" => %{ - triggers: ["apero", "apéro", "apairo", "santai"], - default_coeff: "25", + triggers: ["apero", "apéro", "apairo", "apaireau"], + default_coeff: "", coeffs: %{ - "+" => 2, - "++" => 3, - "+++" => 4, - "++++" => 5, + "" => 1.0, + "+" => 2.0, + "++" => 3.0, + "+++" => 4.0, + "++++" => 5.0, } }, "bière" => @@ -37,11 +52,11 @@ defmodule LSG.IRC.AlcologPlugin do triggers: ["beer", "bière", "biere", "biaire"], default_coeff: "25", coeffs: %{ - "25" => 1, - "50" => 2, - "75" => 3, - "100" => 4, - "+" => 2, + "25" => 1.0, + "50" => 2.0, + "75" => 3.0, + "100" => 4.0, + "+" => 2.0, "++" => 3, "+++" => 4, "++++" => 5, @@ -64,34 +79,104 @@ defmodule LSG.IRC.AlcologPlugin do triggers: ["shot", "royaume", "whisky", "rhum", "armagnac", "dijo"], default_coeff: "", coeffs: %{ - "" => 3, - "+" => 5, - "++" => 7, - "+++" => 9, - "++++" => 11 - } - }, - "eau" => %{ - triggers: ["eau"], - default_coeff: "1", - coeffs: %{ - "1" => -2, - "+" => -3, - "++" => -4, - "+++" => -6, - "++++" => -8 + "" => 1, + "+" => 2, + "++" => 3, + "+++" => 4, + "++++" => 5 } } } + @user_states %{ + :sober => %{ + false => ["n'a pas encore bu", "est actuellement sobre"], + true => ["commence a peine... santé"], + }, + :low => %{ + false => ["est quasiment sobre", "est entrain de redescendre et devrait reboire"], + true => ["commence a monter"], + }, + :legal => %{ + true => ["peut encore conduire, mais plus pour longtemps"], + false => ["peut reconduire", "est en route vers la sobriété"], + }, + :legalhigh => %{ + true => ["est bientôt bourraide", "peut plus conduire"], + false => ["commence a débourrai"], + }, + :high => %{ + true => ["est bourraide!", "est raide"], + false => ["est bourraide mais redescend!"], + }, + :toohigh => ["est totalement bourraide", "est raiiideeeeee"], + :sick => ["est BEAUCOUP trop bourraide"], + } + - @santai ["SANTÉ", "SANTÉ", "SANTAIIII", "SANTAIIIIIIIIII", "SANTAI", "A LA TIENNE"] + @santai [ + "SANTÉ", + "SANTAIIII", + "SANTAI", + "A LA TIENNE", + "CUL SEC CUL SEC", + "TCHIN TCHIN", + "LONGUE VIE", + "ZUM WOHL", "PROST", + "Կէնաձդ", + "Наздраве", + "SANTAIIIIIIIIII", + "AGLOU", + "AUNG MYIN PAR SAY", + "SALUD", + "SANTAIIIIII", + "ΥΓΕΙΑ", + "Å’KåLÀ MA’LUNA", + "EGÉSZSÉGEDRE", + "FENÉKIG", + "乾杯", + "KANPAI", + "TULGATSGAAYA", + "AGLOU AGLOU", + "БУДЕМ ЗДОРОВЫ", + "НА ЗДОРОВЬЕ", + "SANTAIIIIIIIIIIIII", + "MỘT HAI BA, YO", + "SKÅL", + "NA ZDRAVJE", + "AGLOUUUU AGLOUUU AGLOUUUUUUU", + "فى صحتك", + "GESONDHEID", + "ZHU NIN JIANKANG", + "祝您健康", + "SANTÉ", + "CHEERS" + + ] def irc_doc, do: @moduledoc - def start_link(), do: GenServer.start_link(__MODULE__, []) + 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)} + @default_user_meta %{weight: 77.4, sex: true, loss_factor: 15} + + def data_state() do + dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist + dets_meta_filename = (LSG.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + %{dets: dets_filename, meta: dets_meta_filename, ets: __MODULE__.ETS} + end def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:santai", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolog", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:sobre", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:sobrepour", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:soif", []) {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcoolisme", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:alcool", []) for {_, config} <- @triggers do for trigger <- config.triggers do {:ok, _} = Registry.register(IRC.PubSub, "trigger:#{trigger}", []) @@ -102,56 +187,539 @@ defmodule LSG.IRC.AlcologPlugin do end dets_filename = (LSG.data_path() <> "/" <> "alcoolisme.dets") |> String.to_charlist {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) - {:ok, dets} + ets = :ets.new(__MODULE__.ETS, [:ordered_set, :named_table, :protected, {:read_concurrency, true}]) + dets_meta_filename = (LSG.data_path() <> "/" <> "alcoolisme_meta.dets") |> String.to_charlist + {:ok, meta} = :dets.open_file(dets_meta_filename, [{:type,:set}]) + state = %{dets: dets, meta: meta, ets: ets} + + # Upgrade dets format + update_fun = fn(obj, dets) -> + case obj do + object = {nick, date, volumes, name, comment} -> + :dets.delete_object(dets, object) + :dets.insert(dets, {nick, date, volumes || 0.0, 0, name, comment}) + dets + object = {nick, date, volumes, active, name, comment} -> + volumes = if is_integer(volumes) do + volumes = volumes+0.0 + :dets.delete_object(dets, object) + :dets.insert(dets, {nick, date, volumes || 0.0, 0, name, comment}) + volumes + else + volumes + end + :ets.insert(ets, {{nick, date}, volumes, active, name, comment}) + dets + _ -> + dets + end + end + :dets.foldl(update_fun, dets, dets) + :dets.sync(dets) + {:ok, 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) + txt = cond do + now.hour >= 0 && now.hour < 6 -> + ["apéro tardif ? Je dis OUI ! SANTAI !"] + now.hour >= 6 && now.hour < 12 -> + ["C'est quand même un peu tôt non ? Prochain apéro #{apero}"] + 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 !" + ] + 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}!" + ] + now.hour >= 14 && now.hour < 18 -> + ["tiens bon! apéro #{apero}", + "courage... apéro dans #{apero}", + "pas encore :'( apéro dans #{apero}" + ] + true -> + [ + "C'EST L'HEURE DE L'APÉRO !!! SANTAIIIIIIIIIIII !!!!" + ] + end + + txt = txt + |> Enum.shuffle() + |> Enum.random() + + m.replyfun.(txt) + + {: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.sender.nick) + stats = get_full_statistics(state, m.sender.nick) + + 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: :bang}}}, state) do + {:ok, token} = LSG.Token.new({:alcoolog, :index, m.channel}) + url = LSGWeb.Router.Helpers.alcoolog_url(LSGWeb.Endpoint, :index, token) + 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.sender.nick) + 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: args, type: :bang}}}, state) do + case args do + [cl, deg | comment] -> + comment = if comment == [] do + nil + else + Enum.join(comment, " ") + end + + {cl, _} = Util.float_paparse(cl) + {deg, _} = Util.float_paparse(deg) + points = Alcool.units(cl, deg) + + if cl > 0 && deg > 0 do + now = DateTime.to_unix(DateTime.utc_now(), :millisecond) + user_meta = get_user_meta(state, m.sender.nick) + name = "#{cl}cl #{deg}°" + old_stats = get_full_statistics(state, m.sender.nick) + :ok = :dets.insert(state.dets, {String.downcase(m.sender.nick), now, points, if(old_stats, do: old_stats.active, else: 0), name, comment}) + true = :ets.insert(state.ets, {{String.downcase(m.sender.nick), now}, points, if(old_stats, do: old_stats.active, else: 0),name, comment}) + sante = @santai |> Enum.shuffle() |> Enum.random() + meta = get_user_meta(state, m.sender.nick) + k = if meta.sex, do: 0.7, else: 0.6 + weight = meta.weight + peak = Float.round((10*points||0.0)/(k*weight), 4) + stats = get_full_statistics(state, m.sender.nick) + 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 + + m.replyfun.("#{sante} #{m.sender.nick}#{up} #{format_points(points)} @#{stats.active}g/l [+#{peak} g/l]" + <> " (15m: #{stats.active15m}, 30m: #{stats.active30m}, 1h: #{stats.active1h}) (sobriété #{at} (dans #{stats.sober_in_s})#{sober_add}) !" + <> " (aujourd'hui #{stats.daily_volumes} points - #{stats.daily_gl} g/l)") + else + m.replyfun.("on ne peut pas boire en négatif...") + end + _ -> + m.replyfun.("!santai <cl> <degrés> [commentaire]") + end + {:noreply, state} + end + + def get_channel_statistics(channel) do + IRC.UserTrack.to_list() + |> Enum.filter(fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, channel) end) + |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) + |> Enum.map(fn(nick) -> {nick, get_full_statistics(nick)} 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_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_type, last_descr}} -> + {active, active_drinks} = current_alcohol_level(state, nick) + {rising, m30} = alcohol_level_rising(state, nick) + {rising, m15} = alcohol_level_rising(state, nick, 15) + {_, 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 + states = Map.get(@user_states, user_state) + list = if is_list(states) do + states + else + Map.get(states, rising) + end + 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 + + user_status = list + |> Enum.shuffle() + |> Enum.random() + {total_volumes, total_gl} = user_stats(state, nick) + + + %{active: active, last_at: last_at, last_points: last_points, last_type: last_type, last_descr: last_descr, + trend_symbol: trend, + 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 + } + _ -> + nil + end + end + + def handle_info({:irc, :trigger, "sobre", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :dot}}}, state) do + nicks = Enum.filter(IRC.UserTrack.to_list, fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, m.channel) end) |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) + nicks = nicks + |> Enum.map(fn(nick) -> {nick, get_full_statistics(state, nick)} 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 + nick = case args do + [nick] -> nick + [] -> m.sender.nick + end + + stats = get_full_statistics(state, nick) + 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 + {:noreply, state} + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: [], type: :dot}}}, state) do + nicks = Enum.filter(IRC.UserTrack.to_list, fn({_, nick, _, _, _, _, channels}) -> Map.get(channels, m.channel) end) |> Enum.map(fn({_, nick, _, _, _, _, _}) -> nick end) + nicks = nicks + |> Enum.map(fn(nick) -> {nick, get_full_statistics(state, nick)} 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 + "#{nick} #{status.user_status} #{trend_symbol} #{status.active} g/l" + 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: ["semaine"], type: :bang}}}, state) do + aday = 7*((24 * 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"}, :_, :_, :_, :_}, + [ + {:>, :"$1", {:const, before}}, + ], [:"$_"]} + ] + # tuple ets: {{nick, date}, volumes, current, nom, commentaire} + drinks = :ets.select(state.ets, match) + |> 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}) -> "#{nick}: #{Float.round(count, 4)}" end) + |> Enum.intersperse(", ") + + m.replyfun.("sur 7 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.sender.nick) + 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.sender.nick) + meta = Map.merge(@default_user_meta, %{sex: h, weight: weight, loss_factor: factor}) + put_user_meta(state, m.sender.nick, meta) + msg = if old_meta.weight < meta.weight do + "t'as grossi..." + else + "ok" + end + m.replyfun.(msg) + 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.sender.nick) do + {_, obj = {_, date, points, _last_active, type, descr}} -> + :dets.delete_object(state.dets, obj) + :ets.delete(state.ets, {String.downcase(m.sender.nick), date}) + m.replyfun.("supprimé: #{m.sender.nick} #{points} #{type} #{descr}") + m.replyfun.("faudrait quand même penser à boire") + {:noreply, state} + _ -> + {:noreply, state} + end + end + + def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + nick = case args do + [nick] -> nick + [] -> m.sender.nick + end + if stats = get_full_statistics(state, nick) 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 + msg = "#{nick} #{stats.user_status} " + <> (if stats.active > 0 || stats.active15m > 0 || stats.active30m > 0 || stats.active1h > 0, do: ": #{trend_symbol} #{stats.active}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: "") + <> "— 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 + {:noreply, state} + end + + for {name, config} <- @triggers do coeffs = Map.get(config, "coeffs") default_coeff_value = Map.get(config.coeffs, config.default_coeff) - IO.puts "at triggers #{inspect config}" + #IO.puts "at triggers #{inspect config}" # Handle each trigger for trigger <- config.triggers do # … with a known coeff … for {coef, value} when byte_size(coef) > 0 <- config.coeffs do - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(value), m, args, dets) - {:noreply, dets} + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: [unquote(coef)<>_ | args], type: :bang}}}, state) do + handle_bang(unquote(name), unquote(value), m, args, state) + {:noreply, state} end - def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(value), m, args, dets) - {:noreply, dets} + def handle_info({:irc, :trigger, unquote(trigger)<>unquote(coef), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_bang(unquote(name), unquote(value), m, args, state) + {:noreply, state} end end # … or without - def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - handle_bang(unquote(name), unquote(default_coeff_value), m, args, dets) - {:noreply, dets} + def handle_info({:irc, :trigger, unquote(trigger), m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, state) do + handle_bang(unquote(name), unquote(default_coeff_value), m, args, state) + {:noreply, state} end end end - def handle_info({:irc, :trigger, "alcoolisme", m = %IRC.Message{trigger: %IRC.Trigger{args: args, type: :bang}}}, dets) do - nick = case args do - [nick] -> nick - [] -> m.sender.nick - end - case get_statistics_for_nick(dets, nick) do - {count, {_, last_at, last_points, last_type, last_descr}} -> - m.replyfun.("#{nick} a #{count} points d'alcoolisme. Dernier verre: #{present_type(last_type, - last_descr)} [#{last_points}] #{format_relative_timestamp(last_at)}") - _ -> - m.replyfun.("honteux mais #{nick} n'a pas l'air alcoolique du tout. /kick") - end - {:noreply, dets} + def handle_info(t, state) do + Logger.debug("AlcoologPlugin: unhandled info #{inspect t}") + {:noreply, state} end - defp handle_bang(name, points, message, args, dets) do + defp handle_bang(name, points, message, args, state) do description = case args do [] -> nil [something] -> something @@ -159,16 +727,29 @@ defmodule LSG.IRC.AlcologPlugin do _ -> nil end now = DateTime.to_unix(DateTime.utc_now(), :milliseconds) - :ok = :dets.insert(dets, {String.downcase(message.sender.nick), now, points, name, description}) - {count, {_, last_at, last_points, last_type, last_descr}} = get_statistics_for_nick(dets, message.sender.nick) + points = points+0.0 + user_meta = get_user_meta(state, message.sender.nick) + # TODO: Calculer la perte sur last_active depuis last_at. + # TODO: Ajouter les g/L de la nouvelle boisson. + :ok = :dets.insert(state.dets, {String.downcase(message.sender.nick), now, points, 0, name, description}) + true = :ets.insert(state.ets, {{String.downcase(message.sender.nick), now}, points, 0, name, description}) + {active,_} = current_alcohol_level(state, message.sender.nick) + {count, {_, last_at, last_points, _active, last_type, last_descr}} = get_statistics_for_nick(state, message.sender.nick) sante = @santai |> Enum.shuffle() |> Enum.random() - message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! (total #{count} points)") + k = if user_meta.sex, do: 0.7, else: 0.6 + weight = user_meta.weight + peak = (10*points)/(k*weight) + {_, m30} = alcohol_level_rising(state, message.sender.nick) + {_, m15} = alcohol_level_rising(state, message.sender.nick, 15) + {_, h1} = alcohol_level_rising(state, message.sender.nick, 60) + #message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)}! #{active}~ g/L (total #{Float.round(count+0.0, 4)} points)") + message.replyfun.("#{sante} #{message.sender.nick} #{format_points(points)} @#{active}~ g/l [+#{Float.round(peak+0.0, 4)} g/l] (15m: #{m15}, 30m: #{m30}, 1h: #{h1})! (total #{Float.round(count+0.0, 4)} points)") end - defp get_statistics_for_nick(dets, nick) do - qvc = :dets.lookup(dets, String.downcase(nick)) - IO.puts inspect(qvc) - count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _type, _descr}, acc) -> acc + points end) + defp get_statistics_for_nick(state, nick) do + qvc = :dets.lookup(state.dets, String.downcase(nick)) + |> Enum.sort_by(fn({_, ts, _, _, _, _}) -> ts end, &</2) + count = Enum.reduce(qvc, 0, fn({_nick, _ts, points, _active, _type, _descr}, acc) -> acc + (points||0) end) last = List.last(qvc) || nil {count, last} end @@ -176,19 +757,26 @@ defmodule LSG.IRC.AlcologPlugin do def present_type(type, descr) when descr in [nil, ""], do: "#{type}" def present_type(type, description), do: "#{type} (#{description})" - def format_points(int) when int > 0 do + def format_points(int) when is_integer(int) and int > 0 do "+#{Integer.to_string(int)}" end - def format_points(int) when int < 0 do + 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!(:milliseconds) + |> DateTime.from_unix!(:millisecond) |> Timezone.convert("Europe/Paris") {:ok, relative} = Formatters.Relative.relative_to(date, Timex.now("Europe/Paris"), "{relative}", "fr") @@ -196,5 +784,177 @@ defmodule LSG.IRC.AlcologPlugin do relative <> detail end + + defp put_user_meta(state, nick, meta) do + :dets.insert(state.meta, {{:meta, String.downcase(nick)}, meta}) + :ok + end + + defp get_user_meta(%{meta: meta}, nick) do + case :dets.lookup(meta, {:meta, String.downcase(nick)}) 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 ? + # + + defp user_stats(state = %{ets: ets}, nick) do + meta = get_user_meta(state, nick) + 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, String.downcase(nick)}, :"$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, nick, minutes \\ 30) do + {now, _} = current_alcohol_level(state, nick) + soon_date = DateTime.utc_now + |> DateTime.add(minutes*60, :second) + {soon, _} = current_alcohol_level(state, nick, 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}, nick, now \\ nil) do + meta = get_user_meta(state, nick) + 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) + nick = String.downcase(nick) + #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) + match = [ + {{{:"$1", :"$2"}, :_, :_, :_, :_}, + [ + {:>, :"$2", {:const, before}}, + {:"=:=", {:const, nick}, :"$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 = DateTime.from_unix!(date, :millisecond) + 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 + 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/lsg_irc/alcoolog_announcer_plugin.ex b/lib/lsg_irc/alcoolog_announcer_plugin.ex new file mode 100644 index 0000000..214debd --- /dev/null +++ b/lib/lsg_irc/alcoolog_announcer_plugin.ex @@ -0,0 +1,198 @@ +defmodule LSG.IRC.AlcoologAnnouncerPlugin 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__, []) + + def init(_) do + stats = get_stats() + Process.send_after(self(), :stats, :timer.seconds(30)) + dets_filename = (LSG.data_path() <> "/" <> "alcoologlog.dets") |> String.to_charlist + {:ok, dets} = :dets.open_file(dets_filename, [{:type,:bag}]) + {:ok, {stats, now(), dets}} + end + + def alcohol_reached(old, new, level) do + (old.active < level && new.active >= level) + end + + def alcohol_below(old, new, level) do + (old.active > level && new.active <= level) + end + + + def handle_info(:stats, {old_stats, old_now, dets}) 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.PubSubHandler.privmsg(@channel, line) + :timer.sleep(:timer.seconds(5)) + end + end) + string -> + IRC.PubSubHandler.privmsg(@channel, string) + end + + end + + IO.puts "newstats #{inspect stats}" + events = for {nick, old} <- old_stats do + new = Map.get(stats, nick, nil) + IO.puts "#{nick}: #{inspect(old)} -> #{inspect(new)}" + + if new && new[:active] do + :dets.insert(dets, {nick, DateTime.utc_now(), new[:active]}) + else + :dets.insert(dets, {nick, DateTime.utc_now(), 0.0}) + 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 + {nick, event} + end + + for {nick, 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!", + "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!", + "ç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é", + ] + :sober -> [ + "sobre…", + "/!\\ alerte sobriété /!\\", + "... sobre?!?!", + "attention, t'es sobre :/", + "danger, alcoolémie à 0.0 !", + ] + _ -> 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 "#{nick}: #{message}" + if message, do: IRC.PubSubHandler.privmsg(@channel, "#{nick}: #{message}") + end + + timer() + + IO.puts "tick stats ok" + {:noreply, {stats,now,dets}} + end + + defp now() do + DateTime.utc_now() + |> Timex.Timezone.convert("Europe/Paris") + end + + defp get_stats() do + Enum.into(LSG.IRC.AlcoologPlugin.get_channel_statistics(@channel), %{}) + end + + defp timer() do + Process.send_after(self(), :stats, :timer.seconds(@seconds)) + end + +end diff --git a/lib/lsg_irc/bourosama_plugin.ex b/lib/lsg_irc/bourosama_plugin.ex new file mode 100644 index 0000000..7dde662 --- /dev/null +++ b/lib/lsg_irc/bourosama_plugin.ex @@ -0,0 +1,57 @@ +defmodule LSG.IRC.BoursoramaPlugin 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__, []) + 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 + {:ok, _} = Registry.register(IRC.PubSub, "trigger:cac40", []) + {:ok, _} = Registry.register(IRC.PubSub, "trigger:caca40", []) + {: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/lsg_irc/coronavirus_plugin.ex b/lib/lsg_irc/coronavirus_plugin.ex index 9a017f3..debefa3 100644 --- a/lib/lsg_irc/coronavirus_plugin.ex +++ b/lib/lsg_irc/coronavirus_plugin.ex @@ -1,5 +1,6 @@ defmodule LSG.IRC.CoronavirusPlugin do require Logger + NimbleCSV.define(CovidCsv, separator: ",", escape: "\"") @moduledoc """ # Corona Virus @@ -17,7 +18,9 @@ defmodule LSG.IRC.CoronavirusPlugin do def init(_) do {:ok, _} = Registry.register(IRC.PubSub, "trigger:coronavirus", []) - {data, next} = fetch_data(%{}) + date = Date.add(Date.utc_today(), -2) + {data, _} = fetch_data(%{}, date) + {data, next} = fetch_data(data) :timer.send_after(next, :update) {:ok, %{data: data}} end @@ -28,32 +31,48 @@ defmodule LSG.IRC.CoronavirusPlugin do {:noreply, %{data: data}} end - def handle_info({:irc, :trigger, "coronavirus", m = %IRC.Message{trigger: %{type: :bang, args: args}}}, state) when args in [[], ["morts"], ["confirmés"], ["soignés"], ["malades"]] do + 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)} end) - |> Enum.sort_by(fn({_,count}) -> count end, &>=/2) + |> 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}, index}) -> - "##{index+1}: #{location} #{count}" + |> 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.("Corona virus top 10 #{name}: " <> sorted) + 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.("Corona virus: #{location}: #{data.current} malades, #{data.confirmed} confirmés, #{data.deaths} morts, #{data.recovered} soignés") + 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 @@ -79,41 +98,56 @@ defmodule LSG.IRC.CoronavirusPlugin do {:ok, %HTTPoison.Response{status_code: 200, body: csv}} -> # Parse CSV update data data = csv - |> String.strip() - |> String.split("\n") - |> Enum.drop(1) + |> CovidCsv.parse_string() |> Enum.reduce(%{}, fn(line, acc) -> - [state, region, update, confirmed, deaths, recovered,_lat, _lng] = line - |> String.strip() - |> String.split(",") - - state = String.downcase(state) - region = String.downcase(region) - confirmed = String.to_integer(confirmed) - deaths = String.to_integer(deaths) - recovered = String.to_integer(recovered) - - current = (confirmed - recovered) - deaths - - entry = %{update: update, confirmed: confirmed, deaths: deaths, recovered: recovered, current: current, region: region} - - acc = if state && state != "" do - Map.put(acc, state, entry) - else - acc + case line do + # FIPS,Admin2,Province_State,Country_Region,Last_Update,Lat,Long_,Confirmed,Deaths,Recovered,Active,Combined_Key + [_, _, state, region, update, _lat, _lng, confirmed, deaths, recovered, _active, _combined_key] -> + 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 - - region_entry = Map.get(acc, region, %{update: nil, confirmed: 0, deaths: 0, recovered: 0, current: 0}) - region_entry = %{ - update: region_entry.update || update, - confirmed: region_entry.confirmed + confirmed, - deaths: region_entry.deaths + deaths, - current: region_entry.current + current, - recovered: region_entry.recovered + recovered, - region: true - } - - Map.put(acc, region, region_entry) end) Logger.info "Updated coronavirus database" {data, :timer.minutes(60)} diff --git a/lib/lsg_irc/correction_plugin.ex b/lib/lsg_irc/correction_plugin.ex new file mode 100644 index 0000000..a5d3d31 --- /dev/null +++ b/lib/lsg_irc/correction_plugin.ex @@ -0,0 +1,46 @@ +defmodule LSG.IRC.CorrectionPlugin 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__, []) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "message", []) + {:ok, []} + end + + def handle_info({:irc, :text, m = %IRC.Message{}}, history) do + 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 + {:noreply, history} + else + history = if length(history) > 100 do + {_, history} = List.pop_at(history, 99) + [m | history] + else + [m | history] + end + {:noreply, history} + end + end + +end diff --git a/lib/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex index 61bdbf9..bc9764a 100644 --- a/lib/lsg_irc/link_plugin.ex +++ b/lib/lsg_irc/link_plugin.ex @@ -127,40 +127,83 @@ defmodule LSG.IRC.LinkPlugin do expand_default(acc) end + defp get(url, headers \\ [], options \\ []) do + get_req(:hackney.get(url, headers, <<>>, options)) + end + + defp get_req({:error, reason}) do + {:error, reason} + end + + defp get_req({: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) + + cond do + String.starts_with?(content_type, "text/html") && length <= 30_000_000 -> + get_body(30_000_000, client, <<>>) + true -> + :hackney.close(client) + {:ok, "file: #{content_type}, size: #{length} bytes"} + 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(len, client, acc) when len >= byte_size(acc) do + case :hackney.stream_body(client) do + {:ok, data} -> + get_body(len, client, << acc::binary, data::binary >>) + :done -> + html = Floki.parse(acc) + title = case Floki.find(html, "title") do + [{"title", [], [title]} | _] -> + String.trim(title) + _ -> + nil + end + {:ok, title} + {:error, reason} -> + {:ok, "failed to fetch body: #{inspect reason}"} + end + end + + defp get_body(len, client, _acc) do + :hackney.close(client) + {:ok, "mais il rentrera jamais en ram ce fichier !"} + end + def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do headers = [] options = [follow_redirect: false, max_body_length: 30_000_000] - case HTTPoison.get(URI.to_string(uri), headers, options) do - {:ok, %HTTPoison.Response{status_code: 200, headers: headers, body: body}} -> - headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> - Map.put(acc, String.downcase(key), value) - end) - text = case Map.get(headers, "content-type") do - "text/html"<>_ -> - html = Floki.parse(body) - case Floki.find(html, "title") do - [{"title", [], [title]} | _] -> - title - _ -> - nil - end - other -> - "file: #{other}, size: #{Map.get(headers, "content-length", "?")} bytes" - end + case get(URI.to_string(uri), headers, options) do + {:ok, text} -> {:ok, acc, text} - {:ok, resp = %HTTPoison.Response{headers: headers, status_code: redirect, body: body}} when redirect in 300..399 -> - headers = Enum.reduce(headers, %{}, fn({key, value}, acc) -> - Map.put(acc, String.downcase(key), value) - end) - link = Map.get(headers, "location") + {: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]) - {:ok, %HTTPoison.Response{status_code: code}} -> - {:ok, acc, "Error #{code}"} - {:error, %HTTPoison.Error{reason: reason}} -> + {:error, status, _headers} -> + {:ok, acc, "Error #{status}"} + {:error, reason} -> {:ok, acc, "Error #{to_string(reason)}"} - {:error, error} -> - {:ok, acc, "Error #{inspect(error)}"} end end diff --git a/lib/lsg_irc/link_plugin/imgur.ex b/lib/lsg_irc/link_plugin/imgur.ex index 9ce3cf3..1b8173f 100644 --- a/lib/lsg_irc/link_plugin/imgur.ex +++ b/lib/lsg_irc/link_plugin/imgur.ex @@ -43,10 +43,12 @@ defmodule LSG.IRC.LinkPlugin.Imgur do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, json} = Jason.decode(body) data = json["data"] - IO.puts inspect(json) title = String.slice(data["title"] || data["description"], 0, 180) - nsfw = if data["nsfw"], do: "(NSFW) - ", else: "" - {:ok, "#{nsfw}#{title}"} + 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 diff --git a/lib/lsg_irc/seen_plugin.ex b/lib/lsg_irc/seen_plugin.ex new file mode 100644 index 0000000..8b2178f --- /dev/null +++ b/lib/lsg_irc/seen_plugin.ex @@ -0,0 +1,58 @@ +defmodule LSG.IRC.SeenPlugin do + @moduledoc """ + # seen + + * **!seen `<nick>`** + """ + + def irc_doc, do: @moduledoc + def start_link() do + GenServer.start_link(__MODULE__, []) + end + + def init([]) do + {:ok, _} = Registry.register(IRC.PubSub, "triggers", []) + {:ok, _} = Registry.register(IRC.PubSub, "message", []) + dets_filename = (LSG.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/lsg_irc/txt_plugin.ex b/lib/lsg_irc/txt_plugin.ex index 6b7edbd..ca1be9c 100644 --- a/lib/lsg_irc/txt_plugin.ex +++ b/lib/lsg_irc/txt_plugin.ex @@ -132,6 +132,7 @@ defmodule LSG.IRC.TxtPlugin do def handle_info({:irc, :trigger, "txt", msg = %{trigger: %{type: :bang, args: []}}}, state) do result = Enum.reduce(state.triggers, [], fn({trigger, data}, acc) -> + IO.puts inspect(data) Enum.reduce(data, acc, fn({l, _}, acc) -> [{trigger, l} | acc] end) @@ -140,7 +141,7 @@ defmodule LSG.IRC.TxtPlugin do if !Enum.empty?(result) do {source, line} = Enum.random(result) - msg.replyfun.("#{source}: #{line}") + msg.replyfun.(format_line(line, "#{source}: ")) end {:noreply, state} end @@ -157,7 +158,7 @@ defmodule LSG.IRC.TxtPlugin do if !Enum.empty?(result) do {source, line} = Enum.random(result) - msg.replyfun.("#{source}: #{line}") + msg.replyfun.(["#{source}: ", line]) end {:noreply, state} end @@ -208,11 +209,12 @@ defmodule LSG.IRC.TxtPlugin do # TXT: RANDOM # - def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: []}}}, state) do - {trigger, opts} = clean_trigger(trigger) - line = get_random(state.triggers, trigger, opts) + def handle_info({:irc, :trigger, trigger, msg = %{trigger: %{type: :bang, args: opts}}}, state) do + {trigger, _} = clean_trigger(trigger) + IO.puts "OPTS : #{inspect {trigger, opts}}" + line = get_random(state.triggers, trigger, String.trim(Enum.join(opts, " "))) if line do - msg.replyfun.(line) + msg.replyfun.(format_line(line)) end {:noreply, state} end @@ -317,7 +319,7 @@ defmodule LSG.IRC.TxtPlugin do end end - defp get_random(triggers, trigger, [opt]) do + defp get_random(triggers, trigger, opt) do arg = case Integer.parse(opt) do {pos, ""} -> {:index, pos} {_pos, _some_string} -> {:grep, opt} @@ -372,21 +374,24 @@ defmodule LSG.IRC.TxtPlugin do trigger = trigger |> String.downcase - |> String.replace("à", "a") - |> String.replace("ä", "a") - |> String.replace("â", "a") - |> String.replace("é", "e") - |> String.replace("è", "e") - |> String.replace("ê", "e") - |> String.replace("ë", "e") - |> String.replace("ç", "c") - |> String.replace("ï", "i") - |> String.replace("î", "i") + |> :unicode.characters_to_nfd_binary() |> String.replace(~r/[^a-z0-9]/, "") {trigger, opts} end + defp format_line(line, prefix \\ "") do + prefix <> line + |> String.split("\\\\") + |> Enum.map(fn(line) -> + String.split(line, "\\\\\\\\") + end) + |> List.flatten() + |> Enum.map(fn(line) -> + String.trim(line) + end) + end + def directory() do Application.get_env(:lsg, :data_path) <> "/irc.txt/" end diff --git a/lib/lsg_irc/wolfram_alpha_plugin.ex b/lib/lsg_irc/wolfram_alpha_plugin.ex new file mode 100644 index 0000000..b34db56 --- /dev/null +++ b/lib/lsg_irc/wolfram_alpha_plugin.ex @@ -0,0 +1,47 @@ +defmodule LSG.IRC.WolframAlphaPlugin 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__, []) + end + + def init(_) do + {:ok, _} = Registry.register(IRC.PubSub, "trigger:wa", []) + {: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(:lsg, :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 |