diff --git a/config/config.exs b/config/config.exs index ff936c0..e2d7db9 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,54 +1,54 @@ import Config config :logger, level: :debug config :logger, :console, - format: "$date $time [$level$levelpad] $metadata$message\n", + format: "$date $time [$level] $metadata$message\n", metadata: :all config :phoenix, :json_library, Jason # General application configuration config :nola, namespace: Nola config :nola, :data_path, "priv" config :nola, :brand, name: "Nola", source_url: "https://phab.random.sh/source/Nola/" config :ex_aws, region: "us-east-1", host: "s3.wasabisys.com", s3: [ host: "s3.wasabisys.com", region: "us-east-1", scheme: "https://" ] # Configures the endpoint config :nola, NolaWeb.Endpoint, url: [host: "localhost"], secret_key_base: "cAFb7x2p/D7PdV8/C6Os18uygoD0FVQh3efNEFc5+5L529q3dofZtZye/BG12MRZ", render_errors: [view: NolaWeb.ErrorView, accepts: ~w(html json)], server: true, live_view: [signing_salt: "CHANGE_ME_FFS"], - pubsub: [name: Nola.PubSub, + pubsub: [name: NolaWeb.PubSub, adapter: Phoenix.PubSub.PG2] config :mime, :types, %{"text/event-stream" => ["sse"]} config :nola, :lastfm, api_key: "x", api_secret: "x" config :nola, :youtube, api_key: "x", invidious: "yewtu.be" config :mnesia, dir: '.mnesia/#{Mix.env}/#{node()}' # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{Mix.env}.exs" diff --git a/lib/plugins/account.ex b/lib/plugins/account.ex index 242b290..0377e1c 100644 --- a/lib/plugins/account.ex +++ b/lib/plugins/account.ex @@ -1,188 +1,187 @@ defmodule Nola.Plugins.Account do @moduledoc """ # Account * **account** Get current account id and token * **auth `` ``** 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 ``** 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(Nola.PubSub, "messages:private", []) {:ok, nil} end def handle_info({:irc, :text, m = %Nola.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 : 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 = %Nola.Message{account: account, text: "auth"}}, state) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] predicates = :dets.select(Nola.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 = %Nola.Message{account: account, text: "whoami"}}, state) do users = for user <- Nola.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 = %Nola.Message{account: account, text: "account"}}, state) do account = Nola.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 = %Nola.Message{sender: sender, text: "auth"<>_}}, state) do #account = Nola.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 = %Nola.Message{account: account, text: "set-name "<>name}}, state) do Nola.Account.update_account_name(account, name) m.replyfun.("Name changed: #{name}") {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "disable-sms"}}, state) do if Nola.Account.get_meta(m.account, "sms-number") do Nola.Account.delete_meta(m.account, "sms-number") - m.replfyun.("SMS disabled.") + m.replyfun.("SMS disabled.") else m.replyfun.("SMS already disabled.") end {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "web"}}, state) do - auth_url = Untappd.auth_url() login_url = Nola.AuthToken.new_url(m.account.id, nil) - m.replyfun.("-> " <> login_url) + m.replyfun.("↪:" <> login_url) {:noreply, state} end def handle_info({:irc, :text, m = %Nola.Message{text: "enable-sms"}}, state) do code = String.downcase(EntropyString.small_id()) Nola.Account.put_meta(m.account, "sms-validation-code", code) Nola.Account.put_meta(m.account, "sms-validation-target", m.network) - number = Nola.Plugin.Sms.my_number() + number = Nola.Plugins.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 = %Nola.Message{text: "enable-telegram"}}, state) do code = String.downcase(EntropyString.small_id()) Nola.Account.delete_meta(m.account, "telegram-id") Nola.Account.put_meta(m.account, "telegram-validation-code", code) Nola.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 = %Nola.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 = %Nola.Message{text: "getmeta"<>_}}, state) do result = case String.split(m.text, " ") do ["getmeta"] -> for {k, v} <- Nola.Account.get_all_meta(m.account) do case k do "u:"<>key -> "(user) #{key}: #{v}" key -> "#{key}: #{v}" end end ["getmeta", key] -> value = Nola.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 = %Nola.Message{text: "setusermeta"<>_}}, state) do result = case String.split(m.text, " ") do ["setusermeta", key, value] -> Nola.Account.put_user_meta(m.account, key, value) "ok" _ -> "usage: setusermeta " end m.replyfun.(result) {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp join_account(m, id, token) do old_account = Nola.Account.lookup(m.sender) new_account = Nola.Account.get(id) if new_account && token == new_account.token do case Nola.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 index 28723d0..69bd60c 100644 --- a/lib/plugins/alcoolog.ex +++ b/lib/plugins/alcoolog.ex @@ -1,1229 +1,1229 @@ defmodule Nola.Plugins.Alcoolog do require Logger @moduledoc """ # [alcoolog]({{context_path}}/alcoolog) * **!santai `` ` [annotation]`**: enregistre un nouveau verre de `montant` d'une boisson à `degrés d'alcool`. * **!santai `` ``**: 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 ``**: points par jour, sur X j. * **!alcoolisme `[pseudo]`**: affiche les points d'alcoolisme. * **!alcoolisme `[pseudo]` ``**: affiche les points d'alcoolisme par jour sur X j. * **+alcoolisme `` `` `[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 ``**: affiche tu pourras être sobre pour ``, 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 `` ``**: donne le nombre d'unités d'alcool dans `` à `°`. * **!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(Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "again", m = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do handle_info({:irc, :trigger, "moar", m}, state) end def handle_info({:irc, :trigger, "moar", m = %Nola.Message{trigger: %Nola.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/^(?\d+[.]\d+)cl\s+(?\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 = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = Nola.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) extra = " " <> present_type(name, comment) <> "" Nola.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} <- Nola.Membership.notify_channels(m.account) do user = Nola.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) Nola.Irc.Connection.broadcast_message(net, chan, "#{nick}: #{miss}") end end end end end def handle_info({:irc, :trigger, "santai", m = %Nola.Message{trigger: %Nola.Trigger{args: _, type: :bang}}}, state) do m.replyfun.("!santai [commentaire]") {:noreply, state} end def get_all_stats() do Nola.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 Nola.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 Nola.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() :: %{Nola.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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :dot}}}, state) do nicks = Nola.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, & 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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do account = case args do [nick] -> Nola.Account.find_always_by_nick(m.network, m.channel, nick) [] -> m.account end if account do user = Nola.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 = %Nola.Message{trigger: %Nola.Trigger{args: [], type: :dot}}}, state) do nicks = Nola.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 = %Nola.Message{trigger: %Nola.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 = Nola.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 = Nola.Account.get(nick) user = Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = %Nola.Message{trigger: %Nola.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 = Nola.Membership.notify_channels(m.account) -- [{m.network,m.channel}] for {net, chan} <- notify do user = Nola.UserTrack.find_by_account(net, m.account) nick = if(user, do: user.nick, else: m.account.name) Nola.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 = %Nola.Message{trigger: %Nola.Trigger{args: args, type: :bang}}}, state) do {account, duration} = case args do [nick | rest] -> {Nola.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 = Nola.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, & 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 + def 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 + def 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, & 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/web/controllers/alcoolog_controller.ex b/lib/web/controllers/alcoolog_controller.ex index 8263df1..8d7fc11 100644 --- a/lib/web/controllers/alcoolog_controller.ex +++ b/lib/web/controllers/alcoolog_controller.ex @@ -1,323 +1,323 @@ defmodule NolaWeb.AlcoologController do use NolaWeb, :controller require Logger plug NolaWeb.ContextPlug when action not in [:token] plug NolaWeb.ContextPlug, [restrict: :public] when action in [:token] def token(conn, %{"token" => token}) do case Nola.Token.lookup(token) do {:ok, {:alcoolog, :index, network, channel}} -> index(conn, nil, network, channel) err -> Logger.debug("AlcoologControler: token #{inspect err} invalid") conn |> put_status(404) |> text("Page not found") end end def nick(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) days = String.to_integer(Map.get(params, "days", "180")) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do %{ at: ts |> DateTime.from_unix!(:millisecond), points: points, active: active, cl: cl, deg: deg, type: type, description: descr, meta: meta } end history = Enum.sort(history, &(DateTime.compare(&1.at, &2.at) != :lt)) |> IO.inspect() conn |> assign(:title, "alcoolog #{nick}") |> render("user.html", network: network, profile: profile_account, days: days, nick: nick, history: history, stats: stats) else conn |> put_status(404) |> text("Page not found") end end def nick_stats_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do stats = Nola.Plugins.Alcoolog.get_full_statistics(profile_account.id) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(stats)) else conn |> put_status(404) |> json([]) end end def nick_gls_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do data = Nola.Plugins.Alcoolog.user_over_time_gl(profile_account, count) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> %{date: date, gls: Map.get(data, date, 0)} end) |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) end end def nick_volumes_json(conn = %{assigns: %{account: account}}, params = %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) count = String.to_integer(Map.get(params, "days", "180")) if friend? do data = Nola.Plugins.Alcoolog.user_over_time(profile_account, count) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> %{date: date, volumes: Map.get(data, date, 0)} end) |> Enum.sort(&(Date.compare(&1.date, &2.date) != :gt)) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(filled)) else conn |> put_status(404) |> json([]) end end def nick_log_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do history = for {{nick, ts}, points, active, cl, deg, type, descr, meta} <- Nola.Plugins.Alcoolog.nick_history(profile_account) do %{ at: ts |> DateTime.from_unix!(:millisecond) |> DateTime.to_iso8601(), points: points, active: active, cl: cl, deg: deg, type: type, description: descr, meta: meta } end last = List.last(history) {_, active} = Nola.Plugins.Alcoolog.user_stats(profile_account) last = %{last | active: active, at: DateTime.utc_now() |> DateTime.to_iso8601()} history = history ++ [last] conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) else conn |> put_status(404) |> json([]) end end def nick_history_json(conn = %{assigns: %{account: account}}, %{"network" => network, "nick" => nick}) do profile_account = Nola.Account.find_always_by_nick(network, nick, nick) friend? = Enum.member?(Nola.Membership.friends(account), profile_account.id) if friend? do - history = for {_, date, value} <- Nola.Plugs.AlcoologAnnouncer.log(profile_account) do + history = for {_, date, value} <- Nola.Plugins.AlcoologAnnouncer.log(profile_account) do %{date: DateTime.to_iso8601(date), value: value} end conn |> put_resp_content_type("application/json") |> text(Jason.encode!(history)) else conn |> put_status(404) |> json([]) end end def index(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do index(conn, account, network, NolaWeb.reformat_chan(channel)) end def index(conn = %{assigns: %{account: account}}, _) do index(conn, account, nil, nil) end #def index(conn, params) do # network = Map.get(params, "network") # chan = if c = Map.get(params, "chan") do # NolaWeb.reformat_chan(c) # end # irc_conn = if network do # Nola.Irc.Connection.get_network(network, chan) # end # bot = if(irc_conn, do: irc_conn.nick)# # # conn # |> put_status(403) # |> render("auth.html", network: network, channel: chan, irc_conn: conn, bot: bot) #end def index(conn, account, network, channel) do aday = ((24 * 60)*60) now = DateTime.utc_now() before7 = now |> DateTime.add(-(7*aday), :second) |> DateTime.to_unix(:millisecond) before15 = now |> DateTime.add(-(15*aday), :second) |> DateTime.to_unix(:millisecond) before31 = now |> DateTime.add(-(31*aday), :second) |> DateTime.to_unix(:millisecond) #match = :ets.fun2ms(fn(obj = {{^nick, date}, _, _, _, _}) when date > before -> obj end) match = [ {{{:_, :"$1"}, :_, :_, :_, :_, :_, :_, :_}, [ {:>, :"$1", {:const, before15}}, ], [:"$_"]} ] # tuple ets: {{nick, date}, volumes, current, nom, commentaire} members = Nola.Membership.expanded_members_or_friends(account, network, channel) members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) drinks = :ets.select(Nola.Plugins.Alcoolog.ETS, match) |> Enum.filter(fn({{account, _}, _vol, _cur, _cl, _deg, _name, _cmt, _meta}) -> Enum.member?(members_ids, account) end) |> Enum.map(fn({{account, _}, _, _, _, _, _, _, _} = object) -> {object, Map.get(member_names, account)} end) |> Enum.sort_by(fn({{{_, ts}, _, _, _, _, _, _, _}, _}) -> ts end, &>/2) stats = Nola.Plugins.Alcoolog.get_channel_statistics(account, network, channel) top = Enum.reduce(drinks, %{}, fn({{{account_id, _}, vol, _, _, _, _, _, _}, _}, acc) -> nick = Map.get(member_names, account_id) all = Map.get(acc, nick, 0) Map.put(acc, nick, all + vol) end) |> Enum.sort_by(fn({_nick, count}) -> count end, &>/2) # {date, single_peak} # conn |> assign(:title, "alcoolog") |> render("index.html", network: network, channel: channel, drinks: drinks, top: top, stats: stats) end def index_gls_json(conn = %{assigns: %{account: account}}, %{"network" => network, "chan" => channel}) do count = 30 channel = NolaWeb.reformat_chan(channel) members = Nola.Membership.expanded_members_or_friends(account, network, channel) members_ids = Enum.map(members, fn({account, _, nick}) -> account.id end) member_names = Enum.reduce(members, %{}, fn({account, _, nick}, acc) -> Map.put(acc, account.id, nick) end) delay = count*((24 * 60)*60) now = DateTime.utc_now() start_date = DateTime.utc_now() |> DateTime.shift_zone!("Europe/Paris", Tzdata.TimeZoneDatabase) |> DateTime.add(-delay, :second, Tzdata.TimeZoneDatabase) |> DateTime.to_date() |> Date.to_erl() filled = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> {date, (for {a, _, _} <- members, into: Map.new, do: {Map.get(member_names, a.id, a.id), 0})} end) |> Enum.into(Map.new) gls = Enum.reduce(members, filled, fn({account, _, _}, gls) -> Enum.reduce(Nola.Plugins.Alcoolog.user_over_time_gl(account, count), gls, fn({date, gl}, gls) -> u = Map.get(gls, date, %{}) |> Map.put(Map.get(member_names, account.id, account.id), gl) Map.put(gls, date, u) end) end) dates = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) filled2 = Enum.map(member_names, fn({_, name}) -> history = (:calendar.date_to_gregorian_days(start_date) .. :calendar.date_to_gregorian_days(DateTime.utc_now |> DateTime.to_date() |> Date.to_erl)) |> Enum.to_list |> Enum.map(&(:calendar.gregorian_days_to_date(&1))) |> Enum.map(&Date.from_erl!(&1)) |> Enum.map(fn(date) -> get_in(gls, [date, name]) #%{date: date, gl: get_in(gls, [date, name])} end) if Enum.all?(history, fn(x) -> x == 0 end) do nil else %{name: name, history: history} end end) |> Enum.filter(fn(x) -> x end) conn |> put_resp_content_type("application/json") |> text(Jason.encode!(%{labels: dates, data: filled2})) end def minisync(conn, %{"user_id" => user_id, "key" => key, "value" => value}) do account = Nola.Account.get(user_id) if account do ds = Nola.Plugins.Alcoolog.data_state() meta = Nola.Plugins.Alcoolog.get_user_meta(ds, account.id) case Float.parse(value) do {val, _} -> new_meta = Map.put(meta, String.to_existing_atom(key), val) Nola.Plugins.Alcoolog.put_user_meta(ds, account.id, new_meta) _ -> conn |> put_status(:unprocessable_entity) |> text("invalid value") end else conn |> put_status(:not_found) |> text("not found") end end end diff --git a/mix.exs b/mix.exs index 54af7a8..765b008 100644 --- a/mix.exs +++ b/mix.exs @@ -1,106 +1,106 @@ defmodule Nola.Mixfile do use Mix.Project def project do [ app: :nola, version: version("0.2.7"), elixir: "~> 1.4", elixirc_paths: elixirc_paths(Mix.env), compilers: [:phoenix, :gettext] ++ Mix.compilers, start_permanent: Mix.env == :prod, deps: deps() ] end def application do [ - mod: {LSG.Application, []}, + mod: {Nola.Application, []}, extra_applications: [:logger, :runtime_tools] ] end defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] defp aliases do [ "assets.deploy": ["make -C assets", "phx.digest"] ] end defp deps do [ {:phoenix, "~> 1.6.0-rc.0", override: true}, {:phoenix_pubsub, "~> 2.0"}, {:phoenix_live_reload, "~> 1.0", only: :dev}, {:phoenix_html, "~> 3.0"}, {:phoenix_live_view, "~> 0.16.0"}, {:phoenix_live_dashboard, "~> 0.5"}, {:telemetry, "~> 1.0.0", override: true}, {:telemetry_metrics, "~> 0.6"}, {:telemetry_poller, "~> 0.5"}, {:plug_cowboy, "~> 2.0"}, {:cowlib, "~> 2.9.1", override: true}, {:plug, "~> 1.7"}, {:gettext, "~> 0.11"}, {:httpoison, "~> 1.8", override: true}, {:jason, "~> 1.0"}, {:poison, "~> 4.0", override: true}, {:floki, "~> 0.19.3"}, {:ecto, "~> 3.4"}, {:exirc, git: "https://git.random.sh/ircbot/exirc.git", branch: "fix-who-nick"}, {:distillery, "~> 2.0"}, {:earmark, "~> 1.2"}, {:oauther, "~> 1.1"}, {:extwitter, "~> 0.14.0"}, {:entropy_string, "~> 1.0.0"}, {:abacus, "~> 0.3.3"}, {:ex_chain, github: "eljojo/ex_chain"}, {:timex, "~> 3.6"}, {:muontrap, "~> 0.5.1"}, {:tzdata, "~> 1.0"}, {:nimble_csv, "~> 0.7.0"}, {:backoff, git: "https://github.com/ferd/backoff", branch: "master"}, {:telegram, git: "https://github.com/hrefhref/telegram.git", branch: "master"}, {:ex_aws, "~> 2.0"}, {:ex_aws_s3, "~> 2.0"}, {:gen_magic, git: "https://github.com/hrefhref/gen_magic", branch: "develop"}, {:liquex, "~> 0.3"}, {:html_entities, "0.4.0", override: true}, {:file_size, "~> 3.0"}, {:ex2ms, "~> 1.0"}, {:polyjuice_client, git: "https://git.random.sh/ircbot/polyjuice_client.git", branch: "master", override: true}, {:matrix_app_service, git: "https://git.random.sh/ircbot/matrix_app_service.ex.git", branch: "master"}, {:sentry, "~> 8.0.5"}, {:logger_json, "~> 4.3"}, {:oauth2, "~> 2.0"}, {:powerdnsex, git: "https://git.random.sh/ircbot/powerdnsex.git", branch: "master"}, {:pfx, "~> 0.7.0"}, {:flake_id, "~> 0.1.0"} ] end defp version(v) do {describe, 0} = System.cmd("git", ~w(describe --dirty --broken --all --tags --long)) [_, rest] = String.split(describe, "/", parts: 2) info = rest |> String.trim() |> String.replace("/", "-") env = cond do Mix.env() == :prod -> "" true -> "." <> to_string(Mix.env()) end build_timestamp = DateTime.utc_now() |> DateTime.to_unix() |> to_string() build_date_tag = ".build" <> build_timestamp v <> "+" <> info <> env <> build_date_tag end end