diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index 037b7d6..cff556d 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -1,521 +1,521 @@ defmodule IRC.Connection do require Logger use Ecto.Schema @moduledoc """ # IRC Connection Provides a nicer abstraction over ExIRC's handlers. ## Start connections ``` IRC.Connection.start_link(host: "irc.random.sh", port: 6697, nick: "pouetbot", channels: ["#dev"]) ## PubSub topics * `account` -- accounts change * {:account_change, old_account_id, new_account_id} # Sent when account merged * {:accounts, [{:account, network, channel, nick, account_id}] # Sent on bot join * {:account, network, nick, account_id} # Sent on user join * `message` -- aill messages (without triggers) * `message:private` -- all messages without a channel * `message:#CHANNEL` -- all messages within `#CHANNEL` * `triggers` -- all triggers * `trigger:TRIGGER` -- any message with a trigger `TRIGGER` ## Replying to %Nola.Message{} Each `Nola.Message` comes with a dedicated `replyfun`, to which you only have to pass either: """ def irc_doc, do: nil @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) embedded_schema do field :network, :string field :host, :string field :port, :integer field :nick, :string field :user, :string field :name, :string field :pass, :string field :tls, :boolean, default: false field :channels, {:array, :string}, default: [] end defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%IRC.Connection{} = conn) do spec = %{id: conn.id, start: {IRC.Connection, :start_link, [conn]}, restart: :transient} DynamicSupervisor.start_child(__MODULE__, spec) end @impl true def init(_init_arg) do DynamicSupervisor.init( strategy: :one_for_one, max_restarts: 10, max_seconds: 1 ) end end def changeset(params) do import Ecto.Changeset %__MODULE__{id: EntropyString.large_id()} |> cast(params, [:network, :host, :port, :nick, :user, :name, :pass, :channels, :tls]) |> validate_required([:host, :port, :nick, :user, :name]) |> apply_action(:insert) end def to_tuple(%__MODULE__{} = conn) do {conn.id, conn.network, conn.host, conn.port, conn.nick, conn.user, conn.name, conn.pass, conn.tls, conn.channels, nil} end def from_tuple({id, network, host, port, nick, user, name, pass, tls, channels, _}) do %__MODULE__{id: id, network: network, host: host, port: port, nick: nick, user: user, name: name, pass: pass, tls: tls, channels: channels} end ## -- MANAGER API def setup() do :dets.open_file(dets(), []) end def dets(), do: to_charlist(Nola.data_path("/connections.dets")) def lookup(id) do case :dets.lookup(dets(), id) do [object | _] -> from_tuple(object) _ -> nil end end def connections() do :dets.foldl(fn(object, acc) -> [from_tuple(object) | acc] end, [], dets()) end def start_all() do for conn <- connections(), do: {conn, IRC.Connection.Supervisor.start_child(conn)} end def get_network(network, channel \\ nil) do spec = [{{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [{:==, :"$1", {:const, network}}], [:"$_"]}] results = Enum.map(:dets.select(dets(), spec), fn(object) -> from_tuple(object) end) if channel do Enum.find(results, fn(conn) -> Enum.member?(conn.channels, channel) end) else List.first(results) end end def get_host_nick(host, port, nick) do spec = [{{:_, :_, :"$1", :"$2", :"$3", :_, :_, :_, :_, :_, :_}, [{:andalso, {:andalso, {:==, :"$1", {:const, host}}, {:==, :"$2", {:const, port}}}, {:==, :"$3", {:const, nick}}}], [:"$_"]} ] case :dets.select(dets(), spec) do [object] -> from_tuple(object) [] -> nil end end def delete_connection(%__MODULE__{id: id} = conn) do :dets.delete(dets(), id) stop_connection(conn) :ok end def start_connection(%__MODULE__{} = conn) do IRC.Connection.Supervisor.start_child(conn) end def stop_connection(%__MODULE__{id: id}) do case :global.whereis_name(id) do pid when is_pid(pid) -> GenServer.stop(pid, :normal) _ -> :error end end def add_connection(opts) do case changeset(opts) do {:ok, conn} -> if existing = get_host_nick(conn.host, conn.port, conn.nick) do {:error, {:existing, conn}} else :dets.insert(dets(), to_tuple(conn)) IRC.Connection.Supervisor.start_child(conn) end error -> error end end def update_connection(connection) do :dets.insert(dets(), to_tuple(connection)) end def start_link(conn) do GenServer.start_link(__MODULE__, [conn], name: {:global, conn.id}) end def broadcast_message(net, chan, message) do dispatch("conn", {:broadcast, net, chan, message}, IRC.ConnectionPubSub) end def broadcast_message(list, message) when is_list(list) do for {net, chan} <- list do broadcast_message(net, chan, message) end end def privmsg(channel, line) do GenServer.cast(__MODULE__, {:privmsg, channel, line}) end def init([conn]) do Logger.metadata(conn: conn.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) {:ok, %{client: nil, backoff: backoff, conn: conn, connected_server: nil, connected_port: nil, network: conn.network}, {:continue, :connect}} end @triggers %{ "!" => :bang, "+" => :plus, "-" => :minus, "?" => :query, "." => :dot, "~" => :tilde, "@" => :at, "++" => :plus_plus, "--" => :minus_minus, "!!" => :bang_bang, "??" => :query_query, ".." => :dot_dot, "~~" => :tilde_tilde, "@@" => :at_at } def handle_continue(:connect, state) do client_opts = [] |> Keyword.put(:network, state.conn.network) {:ok, _} = Registry.register(IRC.ConnectionPubSub, "conn", []) client = if state.client && Process.alive?(state.client) do Logger.info("Reconnecting client") state.client else Logger.info("Connecting") {:ok, client} = ExIRC.Client.start_link(debug: false) ExIRC.Client.add_handler(client, self()) client end opts = [{:nodelay, true}] conn_fun = if state.conn.tls, do: :connect_ssl!, else: :connect! apply(ExIRC.Client, conn_fun, [client, to_charlist(state.conn.host), state.conn.port, opts]) {:noreply, %{state | client: client}} end def handle_info(:disconnected, state) do {delay, backoff} = :backoff.fail(state.backoff) Logger.info("#{inspect(self())} Disconnected -- reconnecting in #{inspect delay}ms") Process.send_after(self(), :connect, delay) {:noreply, %{state | backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end def handle_cast({:privmsg, channel, line}, state) do irc_reply(state, {channel, nil}, line) {:noreply, state} end # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{inspect(server)}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) ExIRC.Client.logon(state.client, state.conn.pass || "", state.conn.nick, state.conn.user, state.conn.name) {:noreply, %{state | backoff: backoff, connected_server: server, connected_port: port}} end # Logon successful def handle_info(:logged_in, state) do Logger.info("#{inspect(self())} Logged in") {_, backoff} = :backoff.succeed(state.backoff) Enum.map(state.conn.channels, &ExIRC.Client.join(state.client, &1)) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) when is_binary(network) do Nola.UserTrack.clear_network(state.network) if network != state.network do Logger.warn("Possibly misconfigured network: #{network} != #{state.network}") end {:noreply, state} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do ExIRC.Client.join(state.client, chan) {:noreply, state} end # Received something in a channel def handle_info({:received, text, sender, chan}, state) do user = if user = Nola.UserTrack.find_by_nick(state.network, sender.nick) do user else Logger.error("Could not lookup user for message: #{inspect {state.network, chan, sender.nick}}") user = Nola.UserTrack.joined(chan, sender, []) ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. user end if !user do ExIRC.Client.who(state.client, chan) # Rewho everything in case of need ? We shouldn't not know that user.. Logger.error("Could not lookup user nor create it for message: #{inspect {state.network, chan, sender.nick}}") else if !Map.get(user.options, :puppet) do reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end account = Nola.Account.lookup(sender) message = %Nola.Message{id: FlakeId.get(), transport: :irc, at: NaiveDateTime.utc_now(), text: text, network: state.network, account: account, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} message = case Nola.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["#{message.network}/#{chan}:messages"]) end end {:noreply, state} end # Received a private message def handle_info({:received, text, sender}, state) do reply_fun = fn(text) -> irc_reply(state, {sender.nick, sender}, text) end account = Nola.Account.lookup(sender) message = %Nola.Message{id: FlakeId.get(), transport: :irc, text: text, network: state.network, at: NaiveDateTime.utc_now(), account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} message = case Nola.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) {:noreply, state} end ## -- Broadcast def handle_info({:broadcast, net, account = %Nola.Account{}, message}, state) do if net == state.conn.network do user = Nola.UserTrack.find_by_account(net, account) if user do irc_reply(state, {user.nick, nil}, message) end end {:noreply, state} end def handle_info({:broadcast, net, chan, message}, state) do if net == state.conn.network && Enum.member?(state.conn.channels, chan) do irc_reply(state, {chan, nil}, message) end {:noreply, state} end ## -- UserTrack def handle_info({:joined, channel}, state) do ExIRC.Client.who(state.client, channel) {:noreply, state} end def handle_info({:who, channel, whos}, state) do accounts = Enum.map(whos, fn(who = %ExIRC.Who{nick: nick, operator?: operator}) -> priv = if operator, do: [:operator], else: [] # Don't touch -- on WHO the bot joined, not the users. Nola.UserTrack.joined(channel, who, priv, false) account = Nola.Account.lookup(who) if account do {:account, who.network, channel, who.nick, account.id} end end) |> Enum.filter(fn(x) -> x end) dispatch("account", {:accounts, accounts}) {:noreply, state} end def handle_info({:quit, reason, sender}, state) do Nola.UserTrack.quitted(sender, reason) {:noreply, state} end def handle_info({:joined, channel, sender}, state) do Nola.UserTrack.joined(channel, sender, []) account = Nola.Account.lookup(sender) if account do dispatch("account", {:account, sender.network, channel, sender.nick, account.id}) end {:noreply, state} end def handle_info({:kicked, nick, _by, channel, _reason}, state) do Nola.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do Nola.UserTrack.parted(state.network, channel, nick) {:noreply, state} end def handle_info({:mode, [channel, mode, nick]}, state) do track_mode(state.network, channel, nick, mode) {:noreply, state} end def handle_info({:nick_changed, old_nick, new_nick}, state) do Nola.UserTrack.renamed(state.network, old_nick, new_nick) {:noreply, state} end def handle_info(unhandled, client) do Logger.debug("unhandled: #{inspect unhandled}") {:noreply, client} end def publish(pub), do: publish(pub, []) def publish(m = %Nola.Message{trigger: nil}, keys) do dispatch(["messages"] ++ keys, {:irc, :text, m}) end def publish(m = %Nola.Message{trigger: t = %Nola.Trigger{trigger: trigger}}, keys) do dispatch(["triggers", "#{m.network}/#{m.channel}:triggers", "trigger:"<>trigger], {:irc, :trigger, trigger, m}) end def publish_event(net, event = %{type: _}) when is_binary(net) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) dispatch("#{net}:events", {:irc, :event, event}) end def publish_event({net, chan}, event = %{type: type}) do event = event |> Map.put(:at, NaiveDateTime.utc_now()) |> Map.put(:network, net) |> Map.put(:channel, chan) dispatch("#{net}/#{chan}:events", {:irc, :event, event}) end def dispatch(keys, content, sub \\ Nola.PubSub) def dispatch(key, content, sub) when is_binary(key), do: dispatch([key], content, sub) def dispatch(keys, content, sub) when is_list(keys) do Logger.debug("dispatch #{inspect keys} = #{inspect content}") for key <- keys do spawn(fn() -> Registry.dispatch(sub, key, fn h -> for {pid, _} <- h, do: send(pid, content) end) end) end end # # Triggers # def triggers, do: @triggers for {trigger, name} <- @triggers do def extract_trigger(unquote(trigger)<>text) do text = String.strip(text) [trigger | args] = String.split(text, " ") %Nola.Trigger{type: unquote(name), trigger: String.downcase(trigger), args: args} end end def extract_trigger(_), do: nil # # IRC Replies # # irc_reply(ExIRC.Client pid, {channel or nick, ExIRC.Sender}, binary | replies # replies :: {:kick, reason} | {:kick, nick, reason} | {:mode, mode, nick} defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do - lines = IRC.splitlong(text) + lines = Nola.Irc.Message.splitlong(text) |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) |> List.flatten() outputs = for line <- lines do ExIRC.Client.msg(client, :privmsg, target, line) {:irc, :out, %Nola.Message{id: FlakeId.get(), transport: :irc, network: network, channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now(), meta: %{self: true}}} end for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:kick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, _}, {:kick, nick, reason}) do ExIRC.Client.kick(client, target, nick, reason) end defp irc_reply(%{client: client}, {target, %{nick: nick}}, {:mode, mode}) do ExIRC.Client.mode(%{client: client}, target, mode, nick) end defp irc_reply(%{client: client}, target, {:mode, mode, nick}) do ExIRC.Client.mode(client, target, mode, nick) end defp irc_reply(%{client: client}, target, {:channel_mode, mode}) do ExIRC.Client.mode(client, target, mode) end defp track_mode(network, channel, nick, "+o") do Nola.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) :ok end defp track_mode(network, channel, nick, "-o") do Nola.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) :ok end defp track_mode(network, channel, nick, "+v") do Nola.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) :ok end defp track_mode(network, channel, nick, "-v") do Nola.UserTrack.change_privileges(network, channel, nick, {[], [:voice]}) :ok end defp track_mode(network, channel, nick, mode) do Logger.warn("Unhandled track_mode: #{inspect {nick, mode}}") :ok end defp server(%{conn: %{host: host, port: port}}) do host <> ":" <> to_string(port) end end diff --git a/lib/irc/irc.ex b/lib/irc/irc.ex index a1d97a2..dd1a5d2 100644 --- a/lib/irc/irc.ex +++ b/lib/irc/irc.ex @@ -1,59 +1,49 @@ -defmodule IRC do +defmodule Nola.Irc do + require Logger + + def env(), do: Nola.env(:irc) + def env(key, default \\ nil), do: Keyword.get(env(), key, default) def send_message_as(account, network, channel, text, force_puppet \\ false) do connection = IRC.Connection.get_network(network) if connection && (force_puppet || IRC.PuppetConnection.whereis(account, connection)) do IRC.PuppetConnection.start_and_send_message(account, connection, channel, text) else user = Nola.UserTrack.find_by_account(network, account) nick = if(user, do: user.nick, else: account.name) IRC.Connection.broadcast_message(network, channel, "<#{nick}> #{text}") end end - def register(key) do - case Registry.register(Nola.PubSub, key, []) do - {:ok, _} -> :ok - error -> error - end - end - def admin?(%Message{sender: sender}), do: admin?(sender) def admin?(%{nick: nick, user: user, host: host}) do for {n, u, h} <- Nola.IRC.env(:admins, []) do admin_part_match?(n, nick) && admin_part_match?(u, user) && admin_part_match?(h, host) end |> Enum.any? end defp admin_part_match?(:_, _), do: true defp admin_part_match?(a, a), do: true defp admin_part_match?(_, _), do: false - @max_chars 440 - - def splitlong(string, max_chars \\ 440) + def application_childs do + import Supervisor.Spec - def splitlong(string, max_chars) when is_list(string) do - Enum.map(string, fn(s) -> splitlong(s, max_chars) end) - |> List.flatten() - end + IRC.Connection.setup() - def splitlong(string, max_chars) do - string - |> String.codepoints - |> Enum.chunk_every(max_chars) - |> Enum.map(&Enum.join/1) + [ + worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), + supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), + supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), + ] end - def splitlong_with_prefix(string, prefix, max_chars \\ 440) do - prefix = "#{prefix} " - max_chars = max_chars - (length(String.codepoints(prefix))) - string - |> String.codepoints - |> Enum.chunk_every(max_chars) - |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + # Start plugins first to let them get on connection events. + def after_start() do + Logger.info("Starting connections") + IRC.Connection.start_all() end end diff --git a/lib/irc/message.ex b/lib/irc/message.ex new file mode 100644 index 0000000..3927079 --- /dev/null +++ b/lib/irc/message.ex @@ -0,0 +1,28 @@ +defmodule Nola.Irc.Message do + + @max_chars 440 + + def splitlong(string, max_chars \\ 440) + + def splitlong(string, max_chars) when is_list(string) do + Enum.map(string, fn(s) -> splitlong(s, max_chars) end) + |> List.flatten() + end + + def splitlong(string, max_chars) do + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(&Enum.join/1) + end + + def splitlong_with_prefix(string, prefix, max_chars \\ 440) do + prefix = "#{prefix} " + max_chars = max_chars - (length(String.codepoints(prefix))) + string + |> String.codepoints + |> Enum.chunk_every(max_chars) + |> Enum.map(fn(line) -> prefix <> Enum.join(line) end) + end + +end diff --git a/lib/irc/nola_irc.ex b/lib/irc/nola_irc.ex deleted file mode 100644 index 4ed94d1..0000000 --- a/lib/irc/nola_irc.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule Nola.IRC do - require Logger - - def env(), do: Nola.env(:irc) - def env(key, default \\ nil), do: Keyword.get(env(), key, default) - - def application_childs do - import Supervisor.Spec - - IRC.Connection.setup() - - [ - worker(Registry, [[keys: :duplicate, name: IRC.ConnectionPubSub]], id: :registr_irc_conn), - supervisor(IRC.Connection.Supervisor, [], [name: IRC.Connection.Supervisor]), - supervisor(IRC.PuppetConnection.Supervisor, [], [name: IRC.PuppetConnection.Supervisor]), - ] - end - - # Start plugins first to let them get on connection events. - def after_start() do - Logger.info("Starting connections") - IRC.Connection.start_all() - end - -end diff --git a/lib/nola/application.ex b/lib/nola/application.ex index fa880ea..d56d4cb 100644 --- a/lib/nola/application.ex +++ b/lib/nola/application.ex @@ -1,57 +1,57 @@ defmodule Nola.Application do use Application def start(_type, _args) do import Supervisor.Spec Logger.add_backend(Sentry.LoggerBackend) Nola.Plugins.setup() :ok = Nola.Matrix.setup() :ok = Nola.TelegramRoom.setup() # Define workers and child supervisors to be supervised children = [ supervisor(NolaWeb.Endpoint, []), 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(Registry, [[keys: :duplicate, name: Nola.PubSub]], id: :registry_nola_pubsub), worker(Nola.Membership, []), worker(Nola.Account, []), worker(Nola.UserTrack.Storage, []), worker(Nola.Plugins.Account, []), supervisor(Nola.Plugins.Supervisor, [], [name: Nola.Plugins.Supervisor]), - ] ++ Nola.IRC.application_childs + ] ++ Nola.Irc.application_childs ++ Nola.Matrix.application_childs opts = [strategy: :one_for_one, name: Nola.Supervisor] sup = Supervisor.start_link(children, opts) start_telegram() Nola.Plugins.start_all() - spawn_link(fn() -> Nola.IRC.after_start() end) + 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 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(:nola, :telegram, []), :key) options = [ 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/plugins/link/html.ex b/lib/plugins/link/html.ex index 9b44319..a941aac 100644 --- a/lib/plugins/link/html.ex +++ b/lib/plugins/link/html.ex @@ -1,106 +1,106 @@ defmodule Nola.Plugins.Link.HTML do @behaviour Nola.Plugins.Link @impl true def match(_, _), do: false @impl true def post_match(_url, "text/html"<>_, _header, _opts) do {:body, nil} end def post_match(_, _, _, _), do: false @impl true def post_expand(url, body, _params, _opts) do html = Floki.parse(body) title = collect_title(html) opengraph = collect_open_graph(html) itemprops = collect_itemprops(html) text = if Map.has_key?(opengraph, "title") && Map.has_key?(opengraph, "description") do sitename = if sn = Map.get(opengraph, "site_name") do "#{sn}" else "" end paywall? = if Map.get(opengraph, "article:content_tier", Map.get(itemprops, "article:content_tier", "free")) == "free" do "" else "[paywall] " end section = if section = Map.get(opengraph, "article:section", Map.get(itemprops, "article:section", nil)) do ": #{section}" else "" end date = case DateTime.from_iso8601(Map.get(opengraph, "article:published_time", Map.get(itemprops, "article:published_time", ""))) do {:ok, date, _} -> "#{Timex.format!(date, "%d/%m/%y", :strftime)}. " _ -> "" end uri = URI.parse(url) prefix = "#{paywall?}#{Map.get(opengraph, "site_name", uri.host)}#{section}" prefix = unless prefix == "" do "#{prefix} — " else "" end - [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ IRC.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) + [clean_text("#{prefix}#{Map.get(opengraph, "title")}")] ++ Nola.Irc.Message.splitlong(clean_text("#{date}#{Map.get(opengraph, "description")}")) else clean_text(title) end {:ok, text} end defp collect_title(html) do case Floki.find(html, "title") do [{"title", [], [title]} | _] -> String.trim(title) _ -> nil end end defp collect_open_graph(html) do Enum.reduce(Floki.find(html, "head meta"), %{}, fn(tag, acc) -> case tag do {"meta", values, []} -> name = List.keyfind(values, "property", 0, {nil, nil}) |> elem(1) content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) case name do "og:" <> key -> Map.put(acc, key, content) "article:"<>_ -> Map.put(acc, name, content) _other -> acc end _other -> acc end end) end defp collect_itemprops(html) do Enum.reduce(Floki.find(html, "[itemprop]"), %{}, fn(tag, acc) -> case tag do {"meta", values, []} -> name = List.keyfind(values, "itemprop", 0, {nil, nil}) |> elem(1) content = List.keyfind(values, "content", 0, {nil, nil}) |> elem(1) case name do "article:" <> key -> Map.put(acc, name, content) _other -> acc end _other -> acc end end) end defp clean_text(text) do text |> String.replace("\n", " ") |> HtmlEntities.decode() end end diff --git a/lib/plugins/link/twitter.ex b/lib/plugins/link/twitter.ex index e7f3e63..48e6bae 100644 --- a/lib/plugins/link/twitter.ex +++ b/lib/plugins/link/twitter.ex @@ -1,158 +1,158 @@ defmodule Nola.Plugins.Link.Twitter do @behaviour Nola.Plugins.Link @moduledoc """ # Twitter Link Preview Configuration: needs an API key and auth tokens: ``` config :extwitter, :oauth, [ consumer_key: "zzzzz", consumer_secret: "xxxxxxx", access_token: "yyyyyy", access_token_secret: "ssshhhhhh" ] ``` options: * `expand_quoted`: Add the quoted tweet instead of its URL. Default: true. """ def match(uri = %URI{host: twitter, path: path}, _opts) when twitter in ["twitter.com", "m.twitter.com", "mobile.twitter.com"] do case String.split(path, "/", parts: 4) do ["", _username, "status", status_id] -> {status_id, _} = Integer.parse(status_id) {true, %{status_id: status_id}} _ -> false end end def match(_, _), do: false @impl true def post_match(_, _, _, _), do: false def expand(_uri, %{status_id: status_id}, opts) do expand_tweet(ExTwitter.show(status_id, tweet_mode: "extended"), opts) end defp expand_tweet(nil, _opts) do :error end defp link_tweet(tweet_or_screen_id_tuple, opts, force_twitter_com \\ false) defp link_tweet({screen_name, id}, opts, force_twitter_com) do path = "/#{screen_name}/status/#{id}" nitter = Keyword.get(opts, :nitter) host = if !force_twitter_com && nitter, do: nitter, else: "twitter.com" "https://#{host}/#{screen_name}/status/#{id}" end defp link_tweet(tweet, opts, force_twitter_com) do link_tweet({tweet.user.screen_name, tweet.id}, opts, force_twitter_com) end defp expand_tweet(tweet, opts) do head = format_tweet_header(tweet, opts) # Format tweet text text = expand_twitter_text(tweet, opts) text = if tweet.quoted_status do quote_url = link_tweet(tweet.quoted_status, opts, true) String.replace(text, quote_url, "") else text end - text = IRC.splitlong(text) + text = Nola.Irc.Message.splitlong(text) reply_to = if tweet.in_reply_to_status_id do reply_url = link_tweet({tweet.in_reply_to_screen_name, tweet.in_reply_to_status_id}, opts) text = if tweet.in_reply_to_screen_name == tweet.user.screen_name, do: "continued from", else: "replying to" <<3, 15, " ↪ ", text::binary, " ", reply_url::binary, 3>> end quoted = if tweet.quoted_status do full_text = tweet.quoted_status |> expand_twitter_text(opts) - |> IRC.splitlong_with_prefix(">") + |> Nola.Irc.Message.splitlong_with_prefix(">") head = format_tweet_header(tweet.quoted_status, opts, details: false, prefix: "↓ quoting") [head | full_text] else [] end #<<2, "#{tweet.user.name} (@#{tweet.user.screen_name})", 2, " ", 3, 61, "#{foot} #{nitter_link}", 3>>, reply_to] ++ text ++ quoted text = [head, reply_to | text] ++ quoted |> Enum.filter(& &1) {:ok, text} end defp expand_twitter_text(tweet, _opts) do text = Enum.reduce(tweet.entities.urls, tweet.full_text, fn(entity, text) -> String.replace(text, entity.url, entity.expanded_url) end) extended = tweet.extended_entities || %{media: []} text = Enum.reduce(extended.media, text, fn(entity, text) -> url = Enum.filter(extended.media, fn(e) -> entity.url == e.url end) |> Enum.map(fn(e) -> cond do e.type == "video" -> e.expanded_url true -> e.media_url_https end end) |> Enum.join(" ") String.replace(text, entity.url, url) end) |> HtmlEntities.decode() end defp format_tweet_header(tweet, opts, format_opts \\ []) do prefix = Keyword.get(format_opts, :prefix, nil) details = Keyword.get(format_opts, :details, true) padded_prefix = if prefix, do: "#{prefix} ", else: "" author = <> link = link_tweet(tweet, opts) {:ok, at} = Timex.parse(tweet.created_at, "%a %b %e %H:%M:%S %z %Y", :strftime) {:ok, formatted_time} = Timex.format(at, "{relative}", :relative) nsfw = if tweet.possibly_sensitive, do: <<3, 52, "NSFW", 3>> rts = if tweet.retweet_count && tweet.retweet_count > 0, do: "#{tweet.retweet_count} RT" likes = if tweet.favorite_count && tweet.favorite_count > 0, do: "#{tweet.favorite_count} ❤︎" qrts = if tweet.quote_count && tweet.quote_count > 0, do: "#{tweet.quote_count} QRT" replies = if tweet.reply_count && tweet.reply_count > 0, do: "#{tweet.reply_count} Reps" dmcad = if tweet.withheld_copyright, do: <<3, 52, "DMCA", 3>> withheld_local = if tweet.withheld_in_countries && length(tweet.withheld_in_countries) > 0 do "Withheld in #{length(tweet.withheld_in_countries)} countries" end verified = if tweet.user.verified, do: <<3, 51, "✔", 3>> meta = if details do [verified, nsfw, formatted_time, dmcad, withheld_local, rts, qrts, likes, replies] else [verified, nsfw, formatted_time, dmcad, withheld_local] end meta = meta |> Enum.filter(& &1) |> Enum.join(" - ") meta = <<3, 15, meta::binary, " → #{link}", 3>> <> end end