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