summaryrefslogtreecommitdiff
path: root/lib/nola_plugins/link_plugin.ex
diff options
context:
space:
mode:
Diffstat (limited to 'lib/nola_plugins/link_plugin.ex')
-rw-r--r--lib/nola_plugins/link_plugin.ex271
1 files changed, 271 insertions, 0 deletions
diff --git a/lib/nola_plugins/link_plugin.ex b/lib/nola_plugins/link_plugin.ex
new file mode 100644
index 0000000..dee78e8
--- /dev/null
+++ b/lib/nola_plugins/link_plugin.ex
@@ -0,0 +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 :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(:nola, __MODULE__, [handlers: []]), :handlers)
+ handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) ->
+ Logger.debug("link: attempt expanding: #{inspect module} for #{inspect uri}")
+ module = Module.concat([module])
+ case module.match(uri, opts) do
+ {true, params} -> {:halt, {module, params, opts}}
+ false -> {:cont, acc}
+ end
+ end)
+ run_expand(acc, handler)
+ end
+
+ def run_expand(acc, nil) do
+ expand_default(acc)
+ end
+
+ def run_expand(acc=[uri|_], {module, params, opts}) do
+ Logger.debug("link: expanding #{inspect uri} with #{inspect module}")
+ case module.expand(uri, params, opts) do
+ {:ok, data} -> {:ok, acc, data}
+ :error -> expand_default(acc)
+ :skip -> nil
+ end
+ rescue
+ e ->
+ Logger.error("link: rescued #{inspect uri} with #{inspect module}: #{inspect e}")
+ Logger.error(Exception.format(:error, e, __STACKTRACE__))
+ expand_default(acc)
+ catch
+ e, b ->
+ Logger.error("link: catched #{inspect uri} with #{inspect module}: #{inspect {e, b}}")
+ expand_default(acc)
+ end
+
+ defp get(url, headers \\ [], options \\ []) do
+ get_req(url, :hackney.get(url, headers, <<>>, options))
+ end
+
+ defp get_req(_, {:error, reason}) do
+ {:error, reason}
+ end
+
+ defp get_req(url, {:ok, 200, headers, client}) do
+ headers = Enum.reduce(headers, %{}, fn({key, value}, acc) ->
+ Map.put(acc, String.downcase(key), value)
+ end)
+ content_type = Map.get(headers, "content-type", "application/octect-stream")
+ length = Map.get(headers, "content-length", "0")
+ {length, _} = Integer.parse(length)
+
+ handlers = Keyword.get(Application.get_env(:nola, __MODULE__, [handlers: []]), :handlers)
+ handler = Enum.reduce_while(handlers, false, fn({module, opts}, acc) ->
+ module = Module.concat([module])
+ try do
+ case module.post_match(url, content_type, headers, opts) do
+ {mode, params} when mode in [:body, :file] -> {:halt, {module, params, opts, mode}}
+ false -> {:cont, acc}
+ end
+ rescue
+ e ->
+ Logger.error(inspect(e))
+ {:cont, false}
+ catch
+ e, b ->
+ Logger.error(inspect({b}))
+ {:cont, false}
+ end
+ end)
+
+ cond do
+ handler != false and length <= 30_000_000 ->
+ case get_body(url, 30_000_000, client, handler, <<>>) do
+ {:ok, _} = ok -> ok
+ :error ->
+ {:ok, "file: #{content_type}, size: #{human_size(length)}"}
+ end
+ #String.starts_with?(content_type, "text/html") && length <= 30_000_000 ->
+ # get_body(url, 30_000_000, client, <<>>)
+ true ->
+ :hackney.close(client)
+ {:ok, "file: #{content_type}, size: #{human_size(length)}"}
+ end
+ end
+
+ defp get_req(_, {:ok, redirect, headers, client}) when redirect in 300..399 do
+ headers = Enum.reduce(headers, %{}, fn({key, value}, acc) ->
+ Map.put(acc, String.downcase(key), value)
+ end)
+ location = Map.get(headers, "location")
+
+ :hackney.close(client)
+ {:redirect, location}
+ end
+
+ defp get_req(_, {:ok, status, headers, client}) do
+ :hackney.close(client)
+ {:error, status, headers}
+ end
+
+ defp get_body(url, len, client, {handler, params, opts, mode} = h, acc) when len >= byte_size(acc) do
+ case :hackney.stream_body(client) do
+ {:ok, data} ->
+ get_body(url, len, client, h, << acc::binary, data::binary >>)
+ :done ->
+ body = case mode do
+ :body -> acc
+ :file ->
+ {:ok, tmpfile} = Plug.Upload.random_file("linkplugin")
+ File.write!(tmpfile, acc)
+ tmpfile
+ end
+ handler.post_expand(url, body, params, opts)
+ {:error, reason} ->
+ {:ok, "failed to fetch body: #{inspect reason}"}
+ end
+ end
+
+ defp get_body(_, len, client, h, _acc) do
+ :hackney.close(client)
+ IO.inspect(h)
+ {:ok, "Error: file over 30"}
+ end
+
+ def expand_default(acc = [uri = %URI{scheme: scheme} | _]) when scheme in ["http", "https"] do
+ Logger.debug("link: expanding #{uri} with default")
+ headers = [{"user-agent", "DmzBot (like TwitterBot)"}]
+ options = [follow_redirect: false, max_body_length: 30_000_000]
+ case get(URI.to_string(uri), headers, options) do
+ {:ok, text} ->
+ {:ok, acc, text}
+ {:redirect, link} ->
+ new_uri = URI.parse(link)
+ #new_uri = %URI{new_uri | scheme: scheme, authority: uri.authority, host: uri.host, port: uri.port}
+ expand_link([new_uri | acc])
+ {:error, status, _headers} ->
+ text = Plug.Conn.Status.reason_phrase(status)
+ {:ok, acc, "Error: HTTP #{text} (#{status})"}
+ {:error, {:tls_alert, {:handshake_failure, err}}} ->
+ {:ok, acc, "TLS Error: #{to_string(err)}"}
+ {:error, reason} ->
+ {:ok, acc, "Error: #{to_string(reason)}"}
+ end
+ end
+
+ # Unsupported scheme, came from a redirect.
+ def expand_default(acc = [uri | _]) do
+ {:ok, [uri], "-> #{URI.to_string(uri)}"}
+ end
+
+
+ defp human_size(bytes) do
+ bytes
+ |> FileSize.new(:b)
+ |> FileSize.scale()
+ |> FileSize.format()
+ end
+end