diff --git a/lib/couch.ex b/lib/couch.ex index e33c045..a4cf811 100644 --- a/lib/couch.ex +++ b/lib/couch.ex @@ -1,104 +1,104 @@ defmodule Couch do @moduledoc """ Simple, no-frills, CouchDB client """ @type base_error :: {:error, :bad_request} | {:error, :unauthorized} | {:error, :server_error} | {:error, :service_unavailable} | {:error, :bad_response} | {:error, HTTPoison.Error.t()} def new(db, params \\ []) do {url, headers, options} = prepare_request([db], [], params) case HTTPoison.put(url, headers, options) do {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] -> :ok {:ok, %HTTPoison.Response{status_code: 412}} -> {:error, :exists} error -> handle_generic_response(error) end end @doc """ Retrieve a document `doc` from `db`. `params` are [documented here](https://docs.couchdb.org/en/3.2.2-docs/api/document/common.html) """ @spec get(String.t(), String.t() | :all_docs, Keyword.t()) :: {:ok, Map.t()} | {:error, :not_found} | {:error, any()} def get(db, doc, params \\ []) def get(db, :all_docs, params), do: get(db, "_all_docs", params) def get(db, doc, params) do {url, headers, options} = prepare_request([db, doc], [], params) case HTTPoison.get(url, headers, options) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, Poison.decode!(body)} error -> handle_generic_response(error) end end @spec post(String.t(), Map.t(), Keyword.t()) :: {:error, :operation_failed} | {:error, :not_found} | {:error, :exists} | {:error, any()} def post(db, data, params \\ []) do {url, headers, options} = prepare_request([db], [{"content-type", "application/json"}], params) with \ {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] <- HTTPoison.post(url, Poison.encode!(data), headers, options), {:json, {:ok, %{"ok" => true, "id" => id, "rev" => rev}}} <- {:json, Poison.decode(body)} do {:ok, id, rev} else {:ok, %HTTPoison.Response{status_code: 409}} -> {:error, :exists} {:json, {:ok, body}} -> Logger.error("couch: operation failed: #{inspect body}") {:error, :operation_failed} error -> handle_generic_response(error) end end @spec put(String.t(), Map.t(), Keyword.t()) :: {:error, :operation_failed} | {:error, :not_found} | {:error, :conflict} | {:error, any()} def put(db, doc = %{"_id" => id, "_rev" => _}, params \\ []) do {url, headers, options} = prepare_request([db, id], [{"content-type", "application/json"}], params) case HTTPoison.put(url, Poison.encode!(doc), headers, options) do {:ok, %HTTPoison.Response{status_code: ok, body: body}} when ok in [201, 202] -> body = Poison.decode!(body) if Map.get(body, "ok") do {:ok, Map.get(body, "id"), Map.get(body, "rev")} else {:error, :operation_failed} end {:ok, %HTTPoison.Response{status_code: 209}} -> {:error, :conflict} error -> handle_generic_response(error) end end defp prepare_request(path, headers \\ [], params \\ [], options \\ []) do - config = Application.get_env(:lsg, :couch) + config = Application.get_env(:nola, :couch) base_url = Keyword.get(config, :url, "http://localhost:5984") path = path |> Enum.filter(& &1) |> Enum.map(fn(url) -> String.replace("/", "%2F") end) |> Path.join() url = base_url |> URI.merge(path) |> to_string() headers = headers ++ [{"accept", "application/json"}, {"user-agent", "#{Nola.brand(:name)} v#{Nola.version()}"}] params = Enum.map(params, fn({k, v}) -> {to_string(k), v} end) client_options = Keyword.get(config, :client_options, []) options = [params: params] ++ options ++ client_options user = Keyword.get(config, :user) pass = Keyword.get(config, :pass) hackney_options = Keyword.get(options, :hackney, []) hackney_options = if user, do: [{:basic_auth, {user, pass}} | hackney_options], else: [] options = [{:hackney, [:insecure | hackney_options]} | options] {url, headers, options} end defp handle_generic_response(%HTTPoison.Response{status_code: code}), do: {:error, Plug.Conn.Status.reason_atom(code)} defp handle_generic_response(%HTTPoison.Error{reason: reason}), do: {:error, reason} end diff --git a/lib/lsg/application.ex b/lib/lsg/application.ex index 1782053..4f3d1da 100644 --- a/lib/lsg/application.ex +++ b/lib/lsg/application.ex @@ -1,56 +1,56 @@ defmodule Nola.Application do use Application # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do import Supervisor.Spec Logger.add_backend(Sentry.LoggerBackend) :ok = Nola.Matrix.setup() :ok = Nola.TelegramRoom.setup() # Define workers and child supervisors to be supervised children = [ # Start the endpoint when the application starts supervisor(NolaWeb.Endpoint, []), # Start your own worker by calling: Nola.Worker.start_link(arg1, arg2, arg3) # worker(Nola.Worker, [arg1, arg2, arg3]), worker(Registry, [[keys: :duplicate, name: Nola.BroadcastRegistry]], id: :registry_broadcast), worker(Nola.IcecastAgent, []), worker(Nola.Token, []), worker(Nola.AuthToken, []), Nola.Subnet, {GenMagic.Pool, [name: Nola.GenMagic, pool_size: 2]}, #worker(Nola.Icecast, []), ] ++ Nola.IRC.application_childs ++ Nola.Matrix.application_childs # See https://hexdocs.pm/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: Nola.Supervisor] sup = Supervisor.start_link(children, opts) start_telegram() spawn_link(fn() -> Nola.IRC.after_start() end) spawn_link(fn() -> Nola.Matrix.after_start() end) spawn_link(fn() -> Nola.TelegramRoom.after_start() end) sup end # Tell Phoenix to update the endpoint configuration # whenever the application is updated. def config_change(changed, _new, removed) do NolaWeb.Endpoint.config_change(changed, removed) :ok end defp start_telegram() do - token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) options = [ - username: Keyword.get(Application.get_env(:lsg, :telegram, []), :nick, "beauttebot"), + username: Keyword.get(Application.get_env(:nola, :telegram, []), :nick, "beauttebot"), purge: false ] telegram = Telegram.Bot.ChatBot.Supervisor.start_link({Nola.Telegram, token, options}) end end diff --git a/lib/lsg/icecast.ex b/lib/lsg/icecast.ex index 60fb45a..5a53192 100644 --- a/lib/lsg/icecast.ex +++ b/lib/lsg/icecast.ex @@ -1,117 +1,117 @@ defmodule Nola.Icecast do use GenServer require Logger @hackney_pool :default @httpoison_opts [hackney: [pool: @hackney_pool]] @fuse __MODULE__ def start_link, do: GenServer.start_link(__MODULE__, [], []) def init(_) do GenServer.cast(self(), :poll) {:ok, nil} end def handle_cast(:poll, state) do state = poll(state) {:noreply, state} end def handle_info(:poll, state) do state = poll(state) {:noreply, state} end defp poll(state) do state = case request(base_url(), :get) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> #update_json_stats(Jason.decode(body)) stats = update_stats(body) if state != stats do Logger.info "Icecast Update: " <> inspect(stats) Nola.IcecastAgent.update(stats) Registry.dispatch(Nola.BroadcastRegistry, "icecast", fn ws -> for {pid, _} <- ws, do: send(pid, {:icecast, stats}) end) stats else state end error -> Logger.error "Icecast HTTP Error: #{inspect error}" state end - interval = Application.get_env(:lsg, :icecast_poll_interval, 60_000) + interval = Application.get_env(:nola, :icecast_poll_interval, 60_000) :timer.send_after(interval, :poll) state end defp update_stats(html) do raw = Floki.find(html, "div.roundbox") |> Enum.map(fn(html) -> html = Floki.raw_html(html) [{"h3", _, ["Mount Point /"<>mount]}] = Floki.find(html, "h3.mount") stats = Floki.find(html, "tr") |> Enum.map(fn({"tr", _, tds}) -> [{"td", _, keys}, {"td", _, values}] = tds key = List.first(keys) value = List.first(values) {key, value} end) |> Enum.into(Map.new) {mount, stats} end) |> Enum.into(Map.new) live? = if Map.get(raw["live"], "Content Type:", false), do: true, else: false np = if live? do raw["live"]["Currently playing:"] else raw["autodj"]["Currently playing:"] end genre = raw["live"]["Genre:"] || nil %{np: np || "", live: live? || false, genre: genre} end defp update_json_stats({:ok, body}) do Logger.debug "JSON STATS: #{inspect body}" end defp update_json_stats(error) do Logger.error "Failed to decode JSON Stats: #{inspect error}" end defp request(uri, method, body \\ [], headers \\ []) do headers = [{"user-agent", "Nola-API[115ans.net, sys.115ans.net] href@random.sh"}] ++ headers options = @httpoison_opts case :ok do #:fuse.ask(@fuse, :sync) do :ok -> run_request(method, uri, body, headers, options) :blown -> :blown end end # This is to work around hackney's behaviour of returning `{:error, :closed}` when a pool connection has been closed # (keep-alive expired). We just retry the request immediatly up to five times. defp run_request(method, uri, body, headers, options), do: run_request(method, uri, body, headers, options, 0) defp run_request(method, uri, body, headers, options, retries) when retries < 4 do case HTTPoison.request(method, uri, body, headers, options) do {:error, :closed} -> run_request(method, uri, body, headers, options, retries + 1) other -> other end end defp run_request(method, uri, body, headers, options, _exceeded_retries), do: {:error, :unavailable} # # -- URIs # defp stats_json_url do base_url() <> "/status-json.xsl" end defp base_url do "http://91.121.59.45:8089" end end diff --git a/lib/lsg/lsg.ex b/lib/lsg/lsg.ex index 11d0e24..0acb76e 100644 --- a/lib/lsg/lsg.ex +++ b/lib/lsg/lsg.ex @@ -1,30 +1,30 @@ defmodule Nola do @default_brand [ name: "Nola, source_url: "https://phab.random.sh/source/Bot/", owner: "Ashamed owner", owner_email: "contact@my.nola.bot" ] - def env(), do: Application.get_env(:lsg) - def env(key, default \\ nil), do: Application.get_env(:lsg, key, default) + def env(), do: Application.get_env(:nola) + def env(key, default \\ nil), do: Application.get_env(:nola, key, default) def brand(), do: env(:brand, @default_brand) def brand(key), do: Keyword.get(brand(), key) def name(), do: brand(:name) def source_url(), do: brand(:source_url) def data_path(suffix) do Path.join(data_path(), suffix) end def data_path do - Application.get_env(:lsg, :data_path) + Application.get_env(:nola, :data_path) end def version do - Application.spec(:lsg)[:vsn] + Application.spec(:nola)[:vsn] end end diff --git a/lib/lsg_irc/base_plugin.ex b/lib/lsg_irc/base_plugin.ex index ee0352e..a2b9ffb 100644 --- a/lib/lsg_irc/base_plugin.ex +++ b/lib/lsg_irc/base_plugin.ex @@ -1,131 +1,131 @@ defmodule Nola.IRC.BasePlugin 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 = IRC.Plugin.enabled() |> Enum.map(fn(mod) -> mod |> Macro.underscore() |> String.split("/", parts: :infinity) |> List.last() |> String.replace("_plugin", "") |> 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.IRC, Macro.camelize(plugin<>"_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 IRC.Plugin.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.IRC, Macro.camelize(plugin<>"_plugin")]) with true <- Code.ensure_loaded?(module), IRC.Plugin.switch(module, true), {:ok, pid} <- IRC.Plugin.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.IRC, Macro.camelize(plugin<>"_plugin")]) with true <- Code.ensure_loaded?(module), pid when is_pid(pid) <- GenServer.whereis(module), :ok <- GenServer.stop(pid), {:ok, pid} <- IRC.Plugin.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.IRC, Macro.camelize(plugin<>"_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(:lsg, :vsn) + {: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() message.replyfun.([ <<"🤖 I am a robot running", 2, "beautte, version #{ver}", 2, " — source: #{Nola.source_url()}">>, "🦾 Elixir #{elixir_ver} #{otp_ver} on #{system}", "👷‍♀️ Owner: href ", "🌍 Web interface: #{url}" ]) {:noreply, nil} end def handle_info(msg, _) do {:noreply, nil} end end diff --git a/lib/lsg_irc/finance_plugin.ex b/lib/lsg_irc/finance_plugin.ex index c1a1771..16d06ee 100644 --- a/lib/lsg_irc/finance_plugin.ex +++ b/lib/lsg_irc/finance_plugin.ex @@ -1,190 +1,190 @@ defmodule Nola.IRC.FinancePlugin do require Logger @moduledoc """ # finance Données de [alphavantage.co](https://alphavantage.co). ## forex / monnaies / crypto-monnaies * **`!forex [MONNAIE2]`**: taux de change entre deux monnaies. * **`!forex `**: converti `montant` entre deux monnaies * **`?currency `**: recherche une monnaie Utiliser le symbole des monnaies (EUR, USD, ...). ## bourses * **`!stocks `** * **`?stocks `** 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(:lsg, :alphavantage, []) + Application.get_env(:nola, :alphavantage, []) |> Keyword.get(:api_key, "demo") end end diff --git a/lib/lsg_irc/last_fm_plugin.ex b/lib/lsg_irc/last_fm_plugin.ex index f29982c..03df675 100644 --- a/lib/lsg_irc/last_fm_plugin.ex +++ b/lib/lsg_irc/last_fm_plugin.ex @@ -1,187 +1,187 @@ defmodule Nola.IRC.LastFmPlugin do require Logger @moduledoc """ # last.fm * **!lastfm|np `[nick|username]`** * **.lastfm|np** * **+lastfm, -lastfm `; ?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(:lsg, :lastfm)[:api_key] + 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(:lsg, :lastfm)[:api_key] + 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/lsg_irc/link_plugin.ex b/lib/lsg_irc/link_plugin.ex index 28e537a..dee78e8 100644 --- a/lib/lsg_irc/link_plugin.ex +++ b/lib/lsg_irc/link_plugin.ex @@ -1,271 +1,271 @@ defmodule Nola.IRC.LinkPlugin do @moduledoc """ # Link Previewer An extensible link previewer for IRC. To extend the supported sites, create a new handler implementing the callbacks. See `link_plugin/` directory for examples. The first in list handler that returns true to the `match/2` callback will be used, and if the handler returns `:error` or crashes, will fallback to the default preview. Unsupported websites will use the default link preview method, which is for html document the title, otherwise it'll use the mimetype and size. ## Configuration: ``` - config :lsg, Nola.IRC.LinkPlugin, + config :nola, Nola.IRC.LinkPlugin, handlers: [ Nola.IRC.LinkPlugin.Youtube: [ invidious: true ], Nola.IRC.LinkPlugin.Twitter: [], Nola.IRC.LinkPlugin.Imgur: [], ] ``` """ @ircdoc """ # Link preview Previews links (just post a link!). Announces real URL after redirections and provides extended support for YouTube, Twitter and Imgur. """ def short_irc_doc, do: false def irc_doc, do: @ircdoc require Logger def start_link() do GenServer.start_link(__MODULE__, [], 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(:lsg, __MODULE__, [handlers: []]), :handlers) + 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(:lsg, __MODULE__, [handlers: []]), :handlers) + 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/lsg_irc/link_plugin/imgur.ex b/lib/lsg_irc/link_plugin/imgur.ex index af0b36a..5d74956 100644 --- a/lib/lsg_irc/link_plugin/imgur.ex +++ b/lib/lsg_irc/link_plugin/imgur.ex @@ -1,96 +1,96 @@ defmodule Nola.IRC.LinkPlugin.Imgur do @behaviour Nola.IRC.LinkPlugin @moduledoc """ # Imgur link preview No options. Needs to have a Imgur API key configured: ``` - config :lsg, :imgur, + 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(:lsg, :imgur, []), :client_id, "42") + 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(:lsg, :imgur, []), :client_id, "42") + 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/lsg_irc/link_plugin/youtube.ex b/lib/lsg_irc/link_plugin/youtube.ex index 1e3a0de..f7c7541 100644 --- a/lib/lsg_irc/link_plugin/youtube.ex +++ b/lib/lsg_irc/link_plugin/youtube.ex @@ -1,72 +1,72 @@ defmodule Nola.IRC.LinkPlugin.YouTube do @behaviour Nola.IRC.LinkPlugin @moduledoc """ # YouTube link preview needs an API key: ``` - config :lsg, :youtube, + 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(:lsg, :youtube)[:api_key] + 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/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex index 9344ce9..f250e85 100644 --- a/lib/lsg_irc/preums_plugin.ex +++ b/lib/lsg_irc/preums_plugin.ex @@ -1,276 +1,276 @@ defmodule Nola.IRC.PreumsPlugin 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(:lsg, Nola.IRC.PreumsPlugin, []) + env = Application.get_env(:nola, Nola.IRC.PreumsPlugin, []) channels = Keyword.get(env, :channels, %{}) channel_settings = Map.get(channels, channel, []) default = Keyword.get(env, :default_tz, "Europe/Paris") Keyword.get(channel_settings, :tz, default) || default end defp handle_preums(%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/lsg_irc/sms_plugin.ex b/lib/lsg_irc/sms_plugin.ex index 2bbf13e..d8f7387 100644 --- a/lib/lsg_irc/sms_plugin.ex +++ b/lib/lsg_irc/sms_plugin.ex @@ -1,165 +1,165 @@ defmodule Nola.IRC.SmsPlugin do @moduledoc """ ## sms * **!sms `` ``** 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(:lsg, :sms, []), :number, "+33000000000") + 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(:lsg, :sms), :account) + 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(:lsg, :sms) + Application.get_env(:nola, :sms) end defp env(key) do Keyword.get(env(), key) end end diff --git a/lib/lsg_irc/txt_plugin.ex b/lib/lsg_irc/txt_plugin.ex index d2bb627..cab912a 100644 --- a/lib/lsg_irc/txt_plugin.ex +++ b/lib/lsg_irc/txt_plugin.ex @@ -1,556 +1,556 @@ defmodule Nola.IRC.TxtPlugin 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 une ligne dans tous les fichiers. * **~txt**: essaie de générer une phrase (markov). * **~txt ``**: essaie de générer une phrase commencant par ``. * **!`FICHIER`**: lis aléatoirement une ligne du fichier `FICHIER`. * **!`FICHIER` ``**: lis la ligne `` du fichier `FICHIER`. * **!`FICHIER` ``**: recherche une ligne contenant `` dans `FICHIER`. * **+txt `**: crée le fichier ``. * **+`FICHIER` ``**: ajoute une ligne `` dans le fichier `FICHIER`. * **-`FICHIER` ``**: supprime la ligne `` du fichier `FICHIER`. * **-txtrw, +txtrw**. op seulement. active/désactive le mode lecture seule. * **+txtlock ``, -txtlock ``**. 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(:lsg, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.Markov.Native) + markov_handler = Keyword.get(Application.get_env(:nola, __MODULE__, []), :markov_handler, Nola.IRC.TxtPlugin.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(:lsg, :data_path) <> "/irc.txt/" + 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/lsg_irc/txt_plugin/markov_py_markovify.ex b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex index cda7853..b610ea8 100644 --- a/lib/lsg_irc/txt_plugin/markov_py_markovify.ex +++ b/lib/lsg_irc/txt_plugin/markov_py_markovify.ex @@ -1,39 +1,39 @@ defmodule Nola.IRC.TxtPlugin.MarkovPyMarkovify do def start_link() do {:ok, nil} end def reload(_data, _markov) do :ok end def sentence(_) do {:ok, run()} end def complete_sentence(sentence, _) do {:ok, run([sentence])} end defp run(args \\ []) do {binary, script} = script() args = [script, Path.expand(Nola.IRC.TxtPlugin.directory()) | args] IO.puts "Args #{inspect args}" case MuonTrap.cmd(binary, args) do {response, 0} -> response {response, code} -> "error #{code}: #{response}" end end defp script() do - default_script = to_string(:code.priv_dir(:lsg)) <> "/irc/txt/markovify.py" - env = Application.get_env(:lsg, Nola.IRC.TxtPlugin, []) + default_script = to_string(:code.priv_dir(:nola)) <> "/irc/txt/markovify.py" + env = Application.get_env(:nola, Nola.IRC.TxtPlugin, []) |> Keyword.get(:py_markovify, []) {Keyword.get(env, :python, "python3"), Keyword.get(env, :script, default_script)} end end diff --git a/lib/lsg_irc/wolfram_alpha_plugin.ex b/lib/lsg_irc/wolfram_alpha_plugin.ex index b553e63..6ee06f0 100644 --- a/lib/lsg_irc/wolfram_alpha_plugin.ex +++ b/lib/lsg_irc/wolfram_alpha_plugin.ex @@ -1,47 +1,47 @@ defmodule Nola.IRC.WolframAlphaPlugin do use GenServer require Logger @moduledoc """ # wolfram alpha * **`!wa `** lance `` 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(:lsg, :wolframalpha, []), :app_id, "NO_APP_ID"), + "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/lsg_irc/youtube_plugin.ex b/lib/lsg_irc/youtube_plugin.ex index 3d2acfb..fb9bea2 100644 --- a/lib/lsg_irc/youtube_plugin.ex +++ b/lib/lsg_irc/youtube_plugin.ex @@ -1,104 +1,104 @@ defmodule Nola.IRC.YouTubePlugin do require Logger @moduledoc """ # youtube * **!yt ``**, !youtube ``: retourne le premier résultat de la `` 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(:lsg, :youtube)[:api_key] + 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 diff --git a/lib/lsg_telegram/room.ex b/lib/lsg_telegram/room.ex index 794cca3..ca8a437 100644 --- a/lib/lsg_telegram/room.ex +++ b/lib/lsg_telegram/room.ex @@ -1,188 +1,188 @@ defmodule Nola.TelegramRoom do require Logger @behaviour Telegram.ChatBot alias Telegram.Api @couch "bot-telegram-rooms" def rooms(), do: rooms(:with_docs) @spec rooms(:with_docs | :ids) :: [Map.t | integer( )] def rooms(:with_docs) do case Couch.get(@couch, :all_docs, include_docs: true) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"doc" => doc} <- rows, do: doc)} error = {:error, _} -> error end end def rooms(:ids) do case Couch.get(@couch, :all_docs) do {:ok, %{"rows" => rows}} -> {:ok, for(%{"id" => id} <- rows, do: id)} error = {:error, _} -> error end end def room(id, opts \\ []) do Couch.get(@couch, id, opts) end # TODO: Create couch def setup() do :ok end def after_start() do for id <- room(:ids), do: Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, Integer.parse(id) |> elem(0)) end @impl Telegram.ChatBot def init(id) when is_integer(id) and id < 0 do - token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) {:ok, chat} = Api.request(token, "getChat", chat_id: id) Logger.metadata(transport: :telegram, id: id, telegram_room_id: id) tg_room = case room(id) do {:ok, tg_room = %{"network" => _net, "channel" => _chan}} -> tg_room {:error, :not_found} -> [net, chan] = String.split(chat["title"], "/", parts: 2) {net, chan} = case IRC.Connection.get_network(net, chan) do %IRC.Connection{} -> {net, chan} _ -> {nil, nil} end {:ok, _id, _rev} = Couch.post(@couch, %{"_id" => id, "network" => net, "channel" => nil}) {:ok, tg_room} = room(id) tg_room end %{"network" => net, "channel" => chan} = tg_room Logger.info("Starting ChatBot for room #{id} \"#{chat["title"]}\" #{inspect tg_room}") irc_plumbed = if net && chan do {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:outputs", plugin: __MODULE__) true else Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") false end {:ok, %{id: id, net: net, chan: chan, irc: irc_plumbed}} end def init(id) do Logger.error("telegram_room: bad id (not room id)", transport: :telegram, id: id, telegram_room_id: id) :ignoree end defp find_or_create_meta_account(from = %{"id" => user_id}, state) do if account = IRC.Account.find_meta_account("telegram-id", user_id) do account else first_name = Map.get(from, "first_name") last_name = Map.get(from, "last_name") name = [first_name, last_name] |> Enum.filter(& &1) |> Enum.join(" ") username = Map.get(from, "username", first_name) account = username |> IRC.Account.new_account() |> IRC.Account.update_account_name(name) |> IRC.Account.put_meta("telegram-id", user_id) Logger.info("telegram_room: created account #{account.id} for telegram user #{user_id}") account end end def handle_update(%{"message" => %{"from" => from = %{"id" => user_id}, "text" => text}}, _token, state) do account = find_or_create_meta_account(from, state) connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, text, true) {:ok, state} end def handle_update(data = %{"message" => %{"from" => from = %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do account = find_or_create_meta_account(from, state) connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, "@ #{lat}, #{lon}", true) {:ok, state} end for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do upload(unquote(type), data, token, state) end end def handle_update(update, token, state) do {:ok, state} end def handle_info({:irc, _, _, message}, state) do handle_info({:irc, nil, message}, state) end def handle_info({:irc, _, message = %IRC.Message{sender: %{nick: nick}, text: text}}, state) do if Map.get(message.meta, :from) == self() do else body = if Map.get(message.meta, :self), do: text, else: "<#{nick}> #{text}" Nola.Telegram.send_message(state.id, body) end {:ok, state} end def handle_info(info, state) do Logger.info("UNhandled #{inspect info}") {:ok, state} end defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => from = %{"id" => user_id}}}, token, state) do account = find_or_create_meta_account(from, state) if account do {content, type} = cond do m["photo"] -> {m["photo"], "photo"} m["voice"] -> {m["voice"], "voice message"} m["video"] -> {m["video"], "video"} m["document"] -> {m["document"], "file"} m["animation"] -> {m["animation"], "gif"} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(m["caption"], do: m["caption"] <> " ", else: "") spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(token, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{token}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:lsg, :s3, []) |> Keyword.get(:bucket), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), ext = Path.extname(file["file_path"]), s3path = "#{account.id}/#{file_unique_id}#{ext}", s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), {:ok, _} <- ExAws.request(s3req) do path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" txt = "#{type}: #{text}#{path}" connection = IRC.Connection.get_network(state.net) IRC.send_message_as(account, state.net, state.chan, txt, true) else error -> Telegram.Api.request(token, "sendMessage", chat_id: chat_id, text: "File upload failed, sorry.") Logger.error("Failed upload from Telegram: #{inspect error}") end end) {:ok, state} end end end diff --git a/lib/lsg_telegram/telegram.ex b/lib/lsg_telegram/telegram.ex index ef5c3b8..1c6a9a9 100644 --- a/lib/lsg_telegram/telegram.ex +++ b/lib/lsg_telegram/telegram.ex @@ -1,233 +1,233 @@ defmodule Nola.Telegram do require Logger @behaviour Telegram.ChatBot def my_path() do "https://t.me/beauttebot" end def send_message(id, text, md2 \\ false) do md = if md2, do: "MarkdownV2", else: "Markdown" - token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) + token = Keyword.get(Application.get_env(:nola, :telegram, []), :key) Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(Nola.Telegram, id) Telegram.Api.request(token, "sendMessage", chat_id: id, text: text, parse_mode: "Markdown") end @impl Telegram.ChatBot def init(chat_id) when chat_id < 0 do {:ok, state} = Nola.TelegramRoom.init(chat_id) {:ok, %{room_state: state}} end def init(chat_id) do Logger.info("Telegram session starting: #{chat_id}") account = IRC.Account.find_meta_account("telegram-id", chat_id) account_id = if account, do: account.id {:ok, %{account: account_id}} end @impl Telegram.ChatBot def handle_update(update, token, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_update(update, token, room_state) {:ok, %{room_state: room_state}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/start"<>_}}, _token, state) do text = "*Welcome to beautte!*\n\nQuery the bot on IRC and say \"enable-telegram\" to continue." send_message(m["chat"]["id"], text) {:ok, %{account: nil}} end def handle_update(%{"message" => m = %{"chat" => %{"type" => "private"}, "text" => text = "/enable"<>_}}, _token, state) do key = case String.split(text, " ") do ["/enable", key | _] -> key _ -> "nil" end #Handled message "1247435154:AAGnSSCnySn0RuVxy_SUcDEoOX_rbF6vdq0" %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591027272, "entities" => # [%{"length" => 7, "offset" => 0, "type" => "bot_command"}], # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 11, "text" => "/enable salope"}, "update_id" => 764148578} account = IRC.Account.find_meta_account("telegram-validation-code", String.downcase(key)) text = if account do net = IRC.Account.get_meta(account, "telegram-validation-target") IRC.Account.put_meta(account, "telegram-id", m["chat"]["id"]) IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) IRC.Account.put_meta(account, "telegram-username", m["chat"]["username"]) IRC.Account.delete_meta(account, "telegram-validation-code") IRC.Account.delete_meta(account, "telegram-validation-target") IRC.Connection.broadcast_message(net, account, "Telegram #{m["chat"]["username"]} account added!") "Yay! Linked to account **#{account.name}**." else "Token invalid" end send_message(m["chat"]["id"], text) {:ok, %{account: account.id}} end #[debug] Unhandled update: %{"message" => # %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591096015, # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "message_id" => 29, # "photo" => [ # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADbQADZVMBAAEaBA", # "file_size" => 9544, "file_unique_id" => "AQADRv09JF0AA2VTAQAB", "height" => 95, "width" => 320}, # %{"file_id" => "AgACAgQAAxkBAAMdXtYyz4RQqLcpOlR6xKK3w3NayHAAAnCzMRuL4bFSgl_cTXMl4m5G_T0kXQADAQADAgADeAADZFMBAAEaBA", # "file_size" => 21420, "file_unique_id" => "AQADRv09JF0AA2RTAQAB", "height" => 148, "width" => 501}]}, # "update_id" => 218161546} for type <- ~w(photo voice video document animation) do def handle_update(data = %{"message" => %{unquote(type) => _}}, token, state) do start_upload(unquote(type), data, token, state) end end #[debug] Unhandled update: %{"callback_query" => # %{ # "chat_instance" => "-7948978714441865930", "data" => "evolu.net/#dmz", # "from" => %{"first_name" => "J", "id" => 2075406, "is_bot" => false, "language_code" => "en", "username" => "ahref"}, # "id" => "8913804780149600", # "message" => %{"chat" => %{"first_name" => "J", "id" => 2075406, "type" => "private", "username" => "ahref"}, # "date" => 1591098553, "from" => %{"first_name" => "devbeautte", "id" => 1293058838, "is_bot" => true, "username" => "devbeauttebot"}, # "message_id" => 62, # "reply_markup" => %{"inline_keyboard" => [[%{"callback_data" => "random/#", "text" => "random/#"}, # %{"callback_data" => "evolu.net/#dmz", "text" => "evolu.net/#dmz"}]]}, # "text" => "Where should I send the file?"} # } # , "update_id" => 218161568} #def handle_update(t, %{"callback_query" => cb = %{"data" => "resend", "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}) do #end def handle_update(%{"callback_query" => cb = %{"data" => "start-upload:"<>target, "id" => id, "message" => m = %{"message_id" => m_id, "chat" => %{"id" => chat_id}, "reply_to_message" => op}}}, t, state) do account = IRC.Account.find_meta_account("telegram-id", chat_id) if account do target = case String.split(target, "/") do ["everywhere"] -> IRC.Membership.of_account(account) [net, chan] -> [{net, chan}] end Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Processing...", reply_markup: %{}) {content, type} = cond do op["photo"] -> {op["photo"], ""} op["voice"] -> {op["voice"], " a voice message"} op["video"] -> {op["video"], ""} op["document"] -> {op["document"], ""} op["animation"] -> {op["animation"], ""} end file = if is_list(content) && Enum.count(content) > 1 do Enum.sort_by(content, fn(p) -> p["file_size"] end, &>=/2) |> List.first() else content end file_id = file["file_id"] file_unique_id = file["file_unique_id"] text = if(op["caption"], do: ": "<> op["caption"] <> "", else: "") resend = %{"inline_keyboard" => [ [%{"text" => "re-share", "callback_data" => "resend"}] ]} spawn(fn() -> with \ {:ok, file} <- Telegram.Api.request(t, "getFile", file_id: file_id), path = "https://api.telegram.org/file/bot#{t}/#{file["file_path"]}", {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.get(path), <> = body, {:ok, magic} <- GenMagic.Pool.perform(Nola.GenMagic, {:bytes, smol_body}), - bucket = Application.get_env(:lsg, :s3, []) |> Keyword.get(:bucket), + bucket = Application.get_env(:nola, :s3, []) |> Keyword.get(:bucket), ext = Path.extname(file["file_path"]), s3path = "#{account.id}/#{file_unique_id}#{ext}", Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "*Uploading...*", reply_markup: %{}, parse_mode: "MarkdownV2"), s3req = ExAws.S3.put_object(bucket, s3path, body, acl: :public_read, content_type: magic.mime_type), {:ok, _} <- ExAws.request(s3req) do path = NolaWeb.Router.Helpers.url(NolaWeb.Endpoint) <> "/files/#{s3path}" sent = for {net, chan} <- target do txt = "sent#{type}#{text} #{path}" IRC.send_message_as(account, net, chan, txt) "#{net}/#{chan}" end if caption = op["caption"], do: as_irc_message(chat_id, caption, account) text = "Sent on " <> Enum.join(sent, ", ") <> " !" Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "_Sent!_", reply_markup: %{}, parse_mode: "MarkdownV2") else error -> Telegram.Api.request(t, "editMessageText", chat_id: chat_id, message_id: m_id, text: "Something failed.", reply_markup: %{}, parse_mode: "MarkdownV2") Logger.error("Failed upload from Telegram: #{inspect error}") end end) end {:ok, state} end def handle_update(%{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}, "text" => text}}, _, state) do account = IRC.Account.find_meta_account("telegram-id", id) if account do as_irc_message(id, text, account) end {:ok, state} end def handle_update(m, _, state) do Logger.debug("Unhandled update: #{inspect m}") {:ok, state} end @impl Telegram.ChatBot def handle_info(info, %{room_state: room_state}) do {:ok, room_state} = Nola.TelegramRoom.handle_info(info, room_state) {:ok, %{room_state: room_state}} end def handle_info(_info, state) do {:ok, state} end defp as_irc_message(id, text, account) do reply_fun = fn(text) -> send_message(id, text) end trigger_text = cond do String.starts_with?(text, "/") -> "/"<>text = text "!"<>text Enum.any?(IRC.Connection.triggers(), fn({trigger, _}) -> String.starts_with?(text, trigger) end) -> text true -> "!"<>text end message = %IRC.Message{ id: FlakeId.get(), transport: :telegram, network: "telegram", channel: nil, text: text, account: account, sender: %ExIRC.SenderInfo{nick: account.name}, replyfun: reply_fun, trigger: IRC.Connection.extract_trigger(trigger_text), at: nil } IRC.Connection.publish(message, ["messages:private", "messages:telegram", "telegram/#{account.id}:messages"]) message end defp start_upload(_type, %{"message" => m = %{"chat" => %{"id" => id, "type" => "private"}}}, token, state) do account = IRC.Account.find_meta_account("telegram-id", id) if account do text = if(m["text"], do: m["text"], else: nil) targets = IRC.Membership.of_account(account) |> Enum.map(fn({net, chan}) -> "#{net}/#{chan}" end) |> Enum.map(fn(i) -> %{"text" => i, "callback_data" => "start-upload:#{i}"} end) kb = if Enum.count(targets) > 1 do [%{"text" => "everywhere", "callback_data" => "start-upload:everywhere"}] ++ targets else targets end |> Enum.chunk_every(2) keyboard = %{"inline_keyboard" => kb} Telegram.Api.request(token, "sendMessage", chat_id: id, text: "Where should I send this file?", reply_markup: keyboard, reply_to_message_id: m["message_id"], parse_mode: "MarkdownV2") end {:ok, state} end end diff --git a/lib/lsg_web/controllers/irc_controller.ex b/lib/lsg_web/controllers/irc_controller.ex index 90d9853..c617e78 100644 --- a/lib/lsg_web/controllers/irc_controller.ex +++ b/lib/lsg_web/controllers/irc_controller.ex @@ -1,101 +1,101 @@ defmodule NolaWeb.IrcController do use NolaWeb, :controller plug NolaWeb.ContextPlug def index(conn, params) do network = Map.get(params, "network") channel = if c = Map.get(params, "chan"), do: NolaWeb.reformat_chan(c) commands = for mod <- Enum.uniq([IRC.Account.AccountPlugin] ++ IRC.Plugin.enabled()) do if is_atom(mod) do identifier = Module.split(mod) |> List.last |> String.replace("Plugin", "") |> Macro.underscore {identifier, mod.irc_doc()} end end |> Enum.filter(& &1) |> Enum.filter(fn({_, doc}) -> doc end) members = cond do network && channel -> Enum.map(IRC.UserTrack.channel(network, channel), fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) true -> IRC.Membership.of_account(conn.assigns.account) end render conn, "index.html", network: network, commands: commands, channel: channel, members: members end def txt(conn, %{"name" => name}) do if String.contains?(name, ".txt") do name = String.replace(name, ".txt", "") data = data() if Map.has_key?(data, name) do lines = Enum.join(data[name], "\n") text(conn, lines) else conn |> put_status(404) |> text("Not found") end else do_txt(conn, name) end end def txt(conn, _), do: do_txt(conn, nil) defp do_txt(conn, nil) do doc = Nola.IRC.TxtPlugin.irc_doc() data = data() main = Enum.filter(data, fn({trigger, _}) -> !String.contains?(trigger, ".") end) |> Enum.into(Map.new) system = Enum.filter(data, fn({trigger, _}) -> String.contains?(trigger, ".") end) |> Enum.into(Map.new) lines = Enum.reduce(main, 0, fn({_, lines}, acc) -> acc + Enum.count(lines) end) conn |> assign(:title, "txt") |> render("txts.html", data: main, doc: doc, files: Enum.count(main), lines: lines, system: system) end defp do_txt(conn, txt) do data = data() base_url = cond do conn.assigns[:chan] -> "/#{conn.assigns.network}/#{NolaWeb.format_chan(conn.assigns.chan)}" true -> "/-" end if lines = Map.get(data, txt) do lines = Enum.map(lines, fn(line) -> line |> String.split("\\\\") |> Enum.intersperse(Phoenix.HTML.Tag.tag(:br)) end) conn |> assign(:breadcrumbs, [{"txt", "#{base_url}/txt"}]) |> assign(:title, "#{txt}.txt") |> render("txt.html", name: txt, data: lines, doc: nil) else conn |> put_status(404) |> text("Not found") end end defp data() do - dir = Application.get_env(:lsg, :data_path) <> "/irc.txt/" + dir = Application.get_env(:nola, :data_path) <> "/irc.txt/" Path.wildcard(dir <> "/*.txt") |> Enum.reduce(%{}, fn(path, m) -> path = String.split(path, "/") file = List.last(path) key = String.replace(file, ".txt", "") data = dir <> file |> File.read! |> String.split("\n") |> Enum.reject(fn(line) -> cond do line == "" -> true !line -> true true -> false end end) Map.put(m, key, data) end) |> Enum.sort |> Enum.into(Map.new) end end diff --git a/lib/lsg_web/controllers/open_id_controller.ex b/lib/lsg_web/controllers/open_id_controller.ex index 94166eb..d3fef5d 100644 --- a/lib/lsg_web/controllers/open_id_controller.ex +++ b/lib/lsg_web/controllers/open_id_controller.ex @@ -1,64 +1,64 @@ defmodule NolaWeb.OpenIdController do use NolaWeb, :controller plug NolaWeb.ContextPlug, restrict: :public require Logger def login(conn, _) do url = OAuth2.Client.authorize_url!(new_client(), scope: "openid", state: Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)) redirect(conn, external: url) end def callback(conn, %{"error" => error_code, "error_description" => error}) do Logger.warn("OpenId error: #{error_code} #{error}") render(conn, "error.html", error: error) end def callback(conn, %{"code" => code, "state" => state}) do with \ client = %{token: %OAuth2.AccessToken{access_token: json}} = OAuth2.Client.get_token!(new_client(), state: state, code: code), {:ok, %{"access_token" => token}} <- Jason.decode(json), client = %OAuth2.Client{client | token: %OAuth2.AccessToken{access_token: token}}, {:ok, %OAuth2.Response{body: body}} <- OAuth2.Client.get(client, "/userinfo"), {:ok, %{"sub" => id, "preferred_username" => username}} <- Jason.decode(body) do if account = conn.assigns.account do if !IRC.Account.get_meta(account, "identity-id") do # XXX: And oidc id not linked yet IRC.Account.put_meta(account, "identity-id", id) end IRC.Account.put_meta(account, "identity-username", username) conn else conn end conn |> put_session(:oidc_id, id) |> put_flash(:info, "Logged in!") |> redirect(to: Routes.path(conn, "/")) else {:error, %OAuth2.Response{status_code: 401}} -> Logger.error("OpenID: Unauthorized token") render(conn, "error.html", error: "The token is invalid.") {:error, %OAuth2.Error{reason: reason}} -> Logger.error("Error: #{inspect reason}") render(conn, "error.html", error: reason) end end def callback(conn, _params) do render(conn, "error.html", error: "Unspecified error.") end defp new_client() do - config = Application.get_env(:lsg, :oidc) + config = Application.get_env(:nola, :oidc) OAuth2.Client.new([ strategy: OAuth2.Strategy.AuthCode, client_id: config[:client_id], client_secret: config[:client_secret], site: config[:base_url], authorize_url: config[:authorize_url], token_url: config[:token_url], redirect_uri: Routes.open_id_url(NolaWeb.Endpoint, :callback) ]) end end diff --git a/lib/lsg_web/endpoint.ex b/lib/lsg_web/endpoint.ex index d8bf962..b0cf9c5 100644 --- a/lib/lsg_web/endpoint.ex +++ b/lib/lsg_web/endpoint.ex @@ -1,62 +1,62 @@ defmodule NolaWeb.Endpoint do use Sentry.PlugCapture - use Phoenix.Endpoint, otp_app: :lsg + use Phoenix.Endpoint, otp_app: :nola # Serve at "/" the static files from "priv/static" directory. # # You should set gzip to true if you are running phoenix.digest # when deploying your static files in production. plug Plug.Static, - at: "/", from: :lsg, gzip: false, + at: "/", from: :nola, gzip: false, only: ~w(assets css js fonts images favicon.ico robots.txt) # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if 42==43 && code_reloading? do socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket plug Phoenix.LiveReloader plug Phoenix.CodeReloader end plug Plug.RequestId plug Plug.Logger plug Plug.Parsers, parsers: [:urlencoded, :multipart, :json], pass: ["*/*"], json_decoder: Jason plug Sentry.PlugContext plug Plug.MethodOverride plug Plug.Head @session_options [store: :cookie, key: "_lsg_key", signing_salt: "+p7K3wrj"] socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] # The session will be stored in the cookie and signed, # this means its contents can be read but not tampered with. # Set :encryption_salt if you would also like to encrypt it. plug Plug.Session, @session_options plug NolaWeb.Router @doc """ Callback invoked for dynamically configuring the endpoint. It receives the endpoint configuration and checks if configuration should be loaded from the system environment. """ def init(_key, config) do if config[:load_from_system_env] do port = System.get_env("PORT") || raise "expected the PORT environment variable to be set" {:ok, Keyword.put(config, :http, [:inet6, port: port])} else {:ok, config} end end end diff --git a/lib/lsg_web/gettext.ex b/lib/lsg_web/gettext.ex index e9a46e9..a43cb0d 100644 --- a/lib/lsg_web/gettext.ex +++ b/lib/lsg_web/gettext.ex @@ -1,24 +1,24 @@ defmodule NolaWeb.Gettext do @moduledoc """ A module providing Internationalization with a gettext-based API. By using [Gettext](https://hexdocs.pm/gettext), your module gains a set of macros for translations, for example: import NolaWeb.Gettext # Simple translation gettext "Here is the string to translate" # Plural translation ngettext "Here is the string to translate", "Here are the strings to translate", 3 # Domain-based translation dgettext "errors", "Here is the error message to translate" See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage. """ - use Gettext, otp_app: :lsg + use Gettext, otp_app: :nola end diff --git a/lib/open_ai.ex b/lib/open_ai.ex index 81f12f4..cc0de27 100644 --- a/lib/open_ai.ex +++ b/lib/open_ai.ex @@ -1,20 +1,20 @@ defmodule OpenAi do def post(path, data, options \\ []) do - config = Application.get_env(:lsg, :openai, []) + config = Application.get_env(:nola, :openai, []) url = "https://api.openai.com#{path}" headers = [{"user-agent", "internal private experiment bot, href@random.sh"}, {"content-type", "application/json"}, {"authorization", "Bearer " <> Keyword.get(config, :key, "unset-api-key")}] options = options ++ [timeout: :timer.seconds(180), recv_timeout: :timer.seconds(180)] with {:ok, json} <- Poison.encode(data), {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- HTTPoison.post(url, json, headers, options), {:ok, data} <- Poison.decode(body) do {:ok, data} else {:ok, %HTTPoison.Response{status_code: code}} -> {:error, Plug.Conn.Status.reason_atom(code)} {:error, %HTTPoison.Error{reason: reason}} -> {:error, reason} end end end diff --git a/lib/untappd.ex b/lib/untappd.ex index d5ac904..7ed3e66 100644 --- a/lib/untappd.ex +++ b/lib/untappd.ex @@ -1,94 +1,94 @@ defmodule Untappd do @env Mix.env @version Mix.Project.config[:version] require Logger def auth_url() do client_id = Keyword.get(env(), :client_id) url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) "https://untappd.com/oauth/authenticate/?client_id=#{client_id}&response_type=code&redirect_url=#{URI.encode(url)}" end def auth_callback(code) do client_id = Keyword.get(env(), :client_id) client_secret = Keyword.get(env(), :client_secret) url = NolaWeb.Router.Helpers.untappd_callback_url(NolaWeb.Endpoint, :callback) params = %{ "client_id" => client_id, "client_secret" => client_secret, "response_type" => code, "redirect_url" => url, "code" => code } case HTTPoison.get("https://untappd.com/oauth/authorize", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> json = Poison.decode!(body) {:ok, get_in(json, ["response", "access_token"])} error -> Logger.error("Untappd auth callback failed: #{inspect error}") :error end end def maybe_checkin(account, beer_id) do if token = IRC.Account.get_meta(account, "untappd-token") do checkin(token, beer_id) else {:error, :no_token} end end def checkin(token, beer_id) do params = get_params(token: token) |> Map.put("timezone", "CEST") |> Map.put("bid", beer_id) form_params = params |> Enum.into([]) case HTTPoison.post("https://api.untappd.com/v4/checkin/add", {:form, form_params}, headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> body = Jason.decode!(body) |> Map.get("response") {:ok, body} {:ok, resp = %HTTPoison.Response{status_code: code, body: body}} -> Logger.warn "Untappd checkin error: #{inspect resp}" {:error, {:http_error, code}} {:error, error} -> {:error, {:http_error, error}} end end def search_beer(query, params \\ []) do params = get_params(params) |> Map.put("q", query) |> Map.put("limit", 10) #|> Map.put("sort", "name") case HTTPoison.get("https://api.untappd.com/v4/search/beer", headers(), params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> {:ok, Jason.decode!(body)} error -> Logger.error("Untappd search error: #{inspect error}") end end def get_params(params) do auth = %{"client_id" => Keyword.get(env(), :client_id), "client_secret" => Keyword.get(env(), :client_secret)} if token = Keyword.get(params, :token) do Map.put(auth, "access_token", token) else auth end end def headers(extra \\ []) do client_id = Keyword.get(env(), :client_id) extra ++ [ {"user-agent", "dmzbot (#{client_id}; #{@version}-#{@env})"} ] end def env() do - Application.get_env(:lsg, :untappd) + Application.get_env(:nola, :untappd) end end