diff --git a/lib/irc/account.ex b/lib/irc/account.ex index 0aa8638..c835d55 100644 --- a/lib/irc/account.ex +++ b/lib/irc/account.ex @@ -1,440 +1,440 @@ defmodule IRC.Account do alias IRC.UserTrack.User @moduledoc """ Account registry.... Maps a network predicate: * `{net, {:nick, nickname}}` * `{net, {:account, account}}` * `{net, {:mask, user@host}}` to an unique identifier, that can be shared over multiple networks. If a predicate cannot be found for an existing account, a new account will be made in the database. To link two existing accounts from different network onto a different one, a merge operation is provided. """ # FIXME: Ensure uniqueness of name? defstruct [:id, :name, :token] @type t :: %__MODULE__{id: id(), name: String.t()} @type id :: String.t() defimpl Inspect, for: __MODULE__ do import Inspect.Algebra def inspect(%{id: id, name: name}, opts) do concat(["#IRC.Account[", id, " ", name, "]"]) end end def file(base) do to_charlist(LSG.data_path() <> "/account_#{base}.dets") end defp from_struct(%__MODULE__{id: id, name: name, token: token}) do {id, name, token} end defp from_tuple({id, name, token}) do %__MODULE__{id: id, name: name, token: token} end def start_link() do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init(_) do {:ok, accounts} = :dets.open_file(file("db"), []) {:ok, meta} = :dets.open_file(file("meta"), []) {:ok, predicates} = :dets.open_file(file("predicates"), [{:type, :set}]) {:ok, %{accounts: accounts, meta: meta, predicates: predicates}} end def get(id) do case :dets.lookup(file("db"), id) do [account] -> from_tuple(account) _ -> nil end end def get_by_name(name) do spec = [{{:_, :"$1", :_}, [{:==, :"$1", {:const, name}}], [:"$_"]}] case :dets.select(file("db"), spec) do [account] -> from_tuple(account) _ -> nil end end def get_meta(%__MODULE__{id: id}, key, default \\ nil) do case :dets.lookup(file("meta"), {id, key}) do [{_, value}] -> (value || default) _ -> default end end @spec find_meta_accounts(String.t()) :: [{account :: t(), value :: String.t()}, ...] @doc "Find all accounts that have a meta of `key`." def find_meta_accounts(key) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$2", {:const, key}}], [{{:"$1", :"$3"}}]}] for {id, val} <- :dets.select(file("meta"), spec), do: {get(id), val} end @doc "Find an account given a specific meta `key` and `value`." @spec find_meta_account(String.t(), String.t()) :: t() | nil def find_meta_account(key, value) do #spec = [{{{:"$1", :"$2"}, :"$3"}, [:andalso, {:==, :"$2", {:const, key}}, {:==, :"$3", {:const, value}}], [:"$1"]}] spec = [{{{:"$1", :"$2"}, :"$3"}, [{:andalso, {:==, :"$2", {:const, key}}, {:==, {:const, value}, :"$3"}}], [:"$1"]}] case :dets.select(file("meta"), spec) do [id] -> get(id) _ -> nil end end def get_all_meta(%__MODULE__{id: id}) do spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, id}}], [{{:"$2", :"$3"}}]}] :dets.select(file("meta"), spec) end def put_user_meta(account = %__MODULE__{}, key, value) do put_meta(account, "u:"<>key, value) end def put_meta(%__MODULE__{id: id}, key, value) do :dets.insert(file("meta"), {{id, key}, value}) end def delete_meta(%__MODULE__{id: id}, key) do :dets.delete(file("meta"), {id, key}) end def all_accounts() do :dets.traverse(file("db"), fn(obj) -> {:continue, from_tuple(obj)} end) end def all_predicates() do :dets.traverse(file("predicates"), fn(obj) -> {:continue, obj} end) end def all_meta() do :dets.traverse(file("meta"), fn(obj) -> {:continue, obj} end) end def merge_account(old_id, new_id) do if old_id != new_id do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, old_id}}], [:"$1"]}] predicates = :dets.select(file("predicates"), spec) for pred <- predicates, do: :ok = :dets.insert(file("predicates"), {pred, new_id}) spec = [{{{:"$1", :"$2"}, :"$3"}, [{:==, :"$1", {:const, old_id}}], [{{:"$2", :"$3"}}]}] metas = :dets.select(file("meta"), spec) for {k,v} <- metas do :dets.delete(file("meta"), {{old_id, k}}) :ok = :dets.insert(file("meta"), {{new_id, k}, v}) end :dets.delete(file("db"), old_id) IRC.Membership.merge_account(old_id, new_id) IRC.UserTrack.merge_account(old_id, new_id) IRC.Connection.dispatch("account", {:account_change, old_id, new_id}) IRC.Connection.dispatch("conn", {:account_change, old_id, new_id}) end :ok end @doc "Find an account by a logged in user" def find_by_nick(network, nick) do do_lookup(%ExIRC.SenderInfo{nick: nick, network: network}, false) end @doc "Always find an account by nickname, even if offline. Uses predicates and then account name." def find_always_by_nick(network, chan, nick) do with \ nil <- find_by_nick(network, nick), nil <- do_lookup(%User{network: network, nick: nick}, false), nil <- get_by_name(nick) do nil else %__MODULE__{} = account -> memberships = IRC.Membership.of_account(account) if Enum.any?(memberships, fn({net, ch}) -> (net == network) or (chan && chan == ch) end) do account else nil end end end def find(something) do do_lookup(something, false) end def lookup(something, make_default \\ true) do account = do_lookup(something, make_default) if account && Map.get(something, :nick) do IRC.Connection.dispatch("account", {:account_auth, Map.get(something, :nick), account.id}) end account end def handle_info(_, state) do {:noreply, state} end def handle_cast(_, state) do {:noreply, state} end def handle_call(_, _, state) do {:noreply, state} end def terminate(_, state) do for {_, dets} <- state do :dets.sync(dets) :dets.close(dets) end end defp do_lookup(message = %IRC.Message{account: account_id}, make_default) when is_binary(account_id) do get(account_id) end defp do_lookup(sender = %ExIRC.Who{}, make_default) do if user = IRC.UserTrack.find_by_nick(sender) do lookup(user, make_default) else #FIXME this will never work with continued lookup by other methods as Who isn't compatible lookup_by_nick(sender, :dets.lookup(file("predicates"), {sender.network,{:nick, sender.nick}}), make_default) end end defp do_lookup(sender = %ExIRC.SenderInfo{}, make_default) do lookup(IRC.UserTrack.find_by_nick(sender), make_default) end defp do_lookup(user = %User{account: id}, make_default) when is_binary(id) do get(id) end defp do_lookup(user = %User{network: server, nick: nick}, make_default) do lookup_by_nick(user, :dets.lookup(file("predicates"), {server,{:nick, nick}}), make_default) end defp do_lookup(nil, _) do nil end defp lookup_by_nick(_, [{_, id}], _make_default) do get(id) end defp lookup_by_nick(user, _, make_default) do #authenticate_by_host(user) if make_default, do: new_account(user), else: nil end defp new_account(%{nick: nick, network: server}) do id = EntropyString.large_id() :dets.insert(file("db"), {id, nick, EntropyString.token()}) :dets.insert(file("predicates"), {{server, {:nick, nick}}, id}) get(id) end def update_account_name(account = %__MODULE__{id: id}, name) do account = %__MODULE__{account | name: name} :dets.insert(file("db"), from_struct(account)) get(id) end def get_predicates(%__MODULE__{} = account) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] :dets.select(file("predicates"), spec) end defmodule AccountPlugin do @moduledoc """ # Account * **account** Get current account id and token * **auth `` ``** Authenticate and link the current nickname to an account * **auth** list authentications methods * **whoami** list currently authenticated users * **enable-sms** Link a SMS number * **enable-telegram** Link a Telegram account """ def irc_doc, do: @moduledoc def start_link(), do: GenServer.start_link(__MODULE__, [], name: __MODULE__) def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "message:private", []) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) {:ok, nil} end def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "help"}}, state) do text = [ "account: show current account and auth token", "auth: show authentications methods", "whoami: list authenticated users", "set-name : set account name", "web: login to web", "enable-sms | disable-sms: enable/change or disable sms", "enable-telegram: link/change telegram", "enable-untappd: link untappd account", "getmeta: show meta datas", "setusermeta: set user meta", ] m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "auth"}}, state) do spec = [{{:"$1", :"$2"}, [{:==, :"$2", {:const, account.id}}], [:"$1"]}] predicates = :dets.select(IRC.Account.file("predicates"), spec) text = for {net, {key, value}} <- predicates, do: "#{net}: #{to_string(key)}: #{value}" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "whoami"}}, state) do users = for user <- IRC.UserTrack.find_by_account(m.account) do chans = Enum.map(user.privileges, fn({chan, _}) -> chan end) |> Enum.join(" ") "#{user.network} - #{user.nick}!#{user.username}@#{user.host} - #{chans}" end m.replyfun.(users) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "account"}}, state) do account = IRC.Account.lookup(m.sender) text = ["Account Id: #{account.id}", "Authenticate to this account from another network: \"auth #{account.id} #{account.token}\" to the other bot!"] m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{sender: sender, text: "auth"<>_}}, state) do #account = IRC.Account.lookup(m.sender) case String.split(m.text, " ") do ["auth", id, token] -> join_account(m, id, token) _ -> m.replyfun.("Invalid parameters") end {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{account: account, text: "set-name "<>name}}, state) do IRC.Account.update_account_name(account, name) m.replyfun.("Name changed: #{name}") {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "disable-sms"}}, state) do if IRC.Account.get_meta(m.account, "sms-number") do IRC.Account.delete_meta(m.account, "sms-number") m.replfyun.("SMS disabled.") else m.replyfun.("SMS already disabled.") end {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "web"}}, state) do auth_url = Untappd.auth_url() login_url = LSG.AuthToken.new_url(m.account.id, nil) m.replyfun.("-> " <> login_url) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "enable-sms"}}, state) do code = String.downcase(EntropyString.small_id()) IRC.Account.put_meta(m.account, "sms-validation-code", code) IRC.Account.put_meta(m.account, "sms-validation-target", m.network) number = LSG.IRC.SmsPlugin.my_number() text = "To enable or change your number for SMS messaging, please send:" <> " \"enable #{code}\" to #{number}" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "enable-telegram"}}, state) do code = String.downcase(EntropyString.small_id()) IRC.Account.delete_meta(m.account, "telegram-id") IRC.Account.put_meta(m.account, "telegram-validation-code", code) IRC.Account.put_meta(m.account, "telegram-validation-target", m.network) text = "To enable or change your number for telegram messaging, please open #{LSG.Telegram.my_path()} and send:" <> " \"/enable #{code}\"" m.replyfun.(text) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "enable-untappd"}}, state) do auth_url = Untappd.auth_url() login_url = LSG.AuthToken.new_url(m.account.id, {:external_redirect, auth_url}) m.replyfun.(["To link your Untappd account, open this URL:", login_url]) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "getmeta"<>_}}, state) do result = case String.split(m.text, " ") do ["getmeta"] -> for {k, v} <- IRC.Account.get_all_meta(m.account) do case k do "u:"<>key -> "(user) #{key}: #{v}" key -> "#{key}: #{v}" end end ["getmeta", key] -> value = IRC.Account.get_meta(m.account, key) text = if value do "#{key}: #{value}" else "#{key} is not defined" end _ -> "usage: getmeta [key]" end m.replyfun.(result) {:noreply, state} end def handle_info({:irc, :text, m = %IRC.Message{text: "setusermet"<>_}}, state) do result = case String.split(m.text, " ") do ["setusermeta", key, value] -> IRC.Account.put_user_meta(m.account, key, value) "ok" _ -> "usage: setusermeta " end m.replyfun.(result) {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp join_account(m, id, token) do old_account = IRC.Account.lookup(m.sender) new_account = IRC.Account.get(id) if new_account && token == new_account.token do case IRC.Account.merge_account(old_account.id, new_account.id) do :ok -> if old_account.id == new_account.id do m.replyfun.("Already authenticated, but hello") else m.replyfun.("Accounts merged!") end _ -> m.replyfun.("Something failed :(") end else m.replyfun.("Invalid token") end end end end diff --git a/lib/irc/connection.ex b/lib/irc/connection.ex index e856114..eff5930 100644 --- a/lib/irc/connection.ex +++ b/lib/irc/connection.ex @@ -1,493 +1,513 @@ 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 %IRC.Message{} Each `IRC.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(LSG.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) |> IO.inspect() 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: nil}, {: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 if state.conn.tls do ExIRC.Client.connect_ssl!(client, state.conn.host, state.conn.port, [])#[{:ifaddr, {45,150,150,33}}]) else ExIRC.Client.connect!(client, state.conn.host, state.conn.port, [])#[{:ifaddr, {45,150,150,33}}]) end {: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 #{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) do IRC.UserTrack.clear_network(network) {:noreply, %{state | network: network}} 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 reply_fun = fn(text) -> irc_reply(state, {chan, sender}, text) end account = IRC.Account.lookup(sender) - message = %IRC.Message{text: text, network: network(state), account: account, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} + message = %IRC.Message{at: NaiveDateTime.utc_now(), text: text, network: network(state), account: account, sender: sender, channel: chan, replyfun: reply_fun, trigger: extract_trigger(text)} message = case IRC.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end - publish(message, ["message:#{chan}", "#{message.network}/#{chan}:message"]) + publish(message, ["#{message.network}/#{chan}:messages"]) {: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 = IRC.Account.lookup(sender) message = %IRC.Message{text: text, network: network(state), account: account, sender: sender, replyfun: reply_fun, trigger: extract_trigger(text)} message = case IRC.UserTrack.messaged(message) do :ok -> message {:ok, message} -> message end - publish(message, ["message:private"]) + publish(message, ["messages:private", "#{message.network}/#{account.id}:messages"]) {:noreply, state} end ## -- Broadcast def handle_info({:broadcast, net, account = %IRC.Account{}, message}, state) do if net == state.conn.network do user = IRC.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. IRC.UserTrack.joined(channel, who, priv, false) account = IRC.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 - IRC.UserTrack.quitted(sender) + def handle_info({:quit, reason, sender}, state) do + IRC.UserTrack.quitted(sender, reason) {:noreply, state} end def handle_info({:joined, channel, sender}, state) do IRC.UserTrack.joined(channel, sender, []) account = IRC.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 IRC.UserTrack.parted(network(state), channel, nick) {:noreply, state} end def handle_info({:parted, channel, %ExIRC.SenderInfo{nick: nick}}, state) do IRC.UserTrack.parted(network(state), channel, nick) {:noreply, state} end def handle_info({:mode, [channel, mode, nick]}, state) do track_mode(network(state), channel, nick, mode) {:noreply, state} end def handle_info({:nick_changed, old_nick, new_nick}, state) do IRC.UserTrack.renamed(network(state), 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 = %IRC.Message{trigger: nil}, keys) do - dispatch(["message"] ++ keys, {:irc, :text, m}) + dispatch(["messages"] ++ keys, {:irc, :text, m}) end def publish(m = %IRC.Message{trigger: t = %IRC.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 \\ IRC.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 - IO.puts "dispatching to #{inspect({sub,keys})} --> #{inspect content}" + 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, " ") %IRC.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(%{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do + defp irc_reply(state = %{client: client, network: network}, {target, _}, text) when is_binary(text) or is_list(text) do lines = IRC.splitlong(text) |> Enum.map(fn(x) -> if(is_list(x), do: x, else: String.split(x, "\n")) end) |> List.flatten() - for line <- lines do + outputs = for line <- lines do ExIRC.Client.msg(client, :privmsg, target, line) + {:irc, :out, %IRC.Message{network: network, channel: target, text: line, sender: %ExIRC.SenderInfo{nick: state.conn.nick}, at: NaiveDateTime.utc_now()}} end + for f <- outputs, do: dispatch(["irc:outputs", "#{network}/#{target}:outputs"], f) case :global.whereis_name({LSG.TelegramRoom, network, target}) do pid when is_pid(pid) -> send(pid, {:raw, text}) _ -> :ok end 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 IRC.UserTrack.change_privileges(network, channel, nick, {[:operator], []}) :ok end defp track_mode(network, channel, nick, "-o") do IRC.UserTrack.change_privileges(network, channel, nick, {[], [:operator]}) :ok end defp track_mode(network, channel, nick, "+v") do IRC.UserTrack.change_privileges(network, channel, nick, {[:voice], []}) :ok end defp track_mode(network, channel, nick, "-v") do IRC.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 defp network(state = %{conn: %{network: network}}) do if network do network else # FIXME performance meheeeee ExIRC.Client.state(state.client)[:network] end end end diff --git a/lib/irc/puppet_connection.ex b/lib/irc/puppet_connection.ex index 68f1425..8c9b218 100644 --- a/lib/irc/puppet_connection.ex +++ b/lib/irc/puppet_connection.ex @@ -1,175 +1,179 @@ defmodule IRC.PuppetConnection do require Logger @min_backoff :timer.seconds(5) @max_backoff :timer.seconds(2*60) @max_idle :timer.minutes(30) defmodule Supervisor do use DynamicSupervisor def start_link() do DynamicSupervisor.start_link(__MODULE__, [], name: __MODULE__) end def start_child(%IRC.Account{id: account_id}, %IRC.Connection{id: connection_id}) do spec = %{id: {account_id, connection_id}, start: {IRC.PuppetConnection, :start_link, [account_id, connection_id]}, 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 whereis(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}) do {:global, name} = name(account_id, connection_id) case :global.whereis_name(name) do :undefined -> nil pid -> pid end end def send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do GenServer.cast(name(account_id, connection_id), {:send_message, channel, text}) end def start_and_send_message(account = %IRC.Account{id: account_id}, connection = %IRC.Connection{id: connection_id}, channel, text) do {:global, name} = name(account_id, connection_id) pid = whereis(account, connection) pid = if !pid do case IRC.PuppetConnection.Supervisor.start_child(account, connection) do {:ok, pid} -> pid {:error, {:already_started, pid}} -> pid end else pid end GenServer.cast(pid, {:send_message, channel, text}) end + def start(account = %IRC.Account{}, connection = %IRC.Connection{}) do + IRC.PuppetConnection.Supervisor.start_child(account, connection) + end + def start_link(account_id, connection_id) do GenServer.start_link(__MODULE__, [account_id, connection_id], name: name(account_id, connection_id)) end def name(account_id, connection_id) do {:global, {PuppetConnection, account_id, connection_id}} end def init([account_id, connection_id]) do account = %IRC.Account{} = IRC.Account.get(account_id) connection = %IRC.Connection{} = IRC.Connection.lookup(connection_id) Logger.metadata(puppet_conn: account.id <> "@" <> connection.id) backoff = :backoff.init(@min_backoff, @max_backoff) |> :backoff.type(:jitter) idle = :erlang.send_after(@max_idle, self, :idle) {:ok, %{client: nil, backoff: backoff, idle: idle, connected: false, buffer: [], channels: [], connection_id: connection_id, account_id: account_id, connected_server: nil, connected_port: nil, network: connection.network}, {:continue, :connect}} end def handle_continue(:connect, state) do conn = IRC.Connection.lookup(state.connection_id) client_opts = [] |> Keyword.put(:network, conn.network) 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 if conn.tls do ExIRC.Client.connect_ssl!(client, conn.host, conn.port, [])#[{:ifaddr, {45,150,150,33}}]) else ExIRC.Client.connect!(client, conn.host, conn.port, [])#[{:ifaddr, {45,150,150,33}}]) end {:noreply, %{state | client: client}} end def handle_continue(:connected, state) do state = Enum.reduce(Enum.reverse(state.buffer), state, fn(b, state) -> {:noreply, state} = handle_cast(b, state) state end) {:noreply, %{state | buffer: []}} end def handle_cast(cast = {:send_message, channel, text}, state = %{connected: false, buffer: buffer}) do {:noreply, %{state | buffer: [cast | buffer]}} end def handle_cast({:send_message, channel, text}, state = %{connected: true}) do channels = if !Enum.member?(state.channels, channel) do ExIRC.Client.join(state.client, channel) [channel | state.channels] else state.channels end ExIRC.Client.msg(state.client, :privmsg, channel, text) idle = if length(state.buffer) == 0 do :erlang.cancel_timer(state.idle) :erlang.send_after(@max_idle, self(), :idle) else state.idle end {:noreply, %{state | idle: idle, channels: channels}} end def handle_info(:idle, state) do ExIRC.Client.quit(state.client, "Puppet was idle for too long") ExIRC.Client.stop!(state.client) {:stop, :normal, state} 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 | connected: false, backoff: backoff}} end def handle_info(:connect, state) do {:noreply, state, {:continue, :connect}} end # Connection successful def handle_info({:connected, server, port}, state) do Logger.info("#{inspect(self())} Connected to #{server}:#{port} #{inspect state}") {_, backoff} = :backoff.succeed(state.backoff) account = IRC.Account.get(state.account_id) user = IRC.UserTrack.find_by_account(state.network, account) base_nick = if(user, do: user.nick, else: account.name) nick = "#{base_nick}[p]" ExIRC.Client.logon(state.client, "", nick, base_nick, "#{base_nick}'s puppet") {: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) {:noreply, %{state | backoff: backoff}} end # ISUP def handle_info({:isup, network}, state) do {:noreply, %{state | network: network, connected: true}, {:continue, :connected}} end # Been kicked def handle_info({:kicked, _sender, chan, _reason}, state) do {:noreply, %{state | channels: state.channels -- [chan]}} end def handle_info(_info, state) do {:noreply, state} end end diff --git a/lib/irc/user_track.ex b/lib/irc/user_track.ex index 5e1c3a3..4b1ee67 100644 --- a/lib/irc/user_track.ex +++ b/lib/irc/user_track.ex @@ -1,291 +1,306 @@ defmodule IRC.UserTrack do @moduledoc """ User Track DB & Utilities """ @ets IRC.UserTrack.Storage # {uuid, network, nick, nicks, privilege_map} # Privilege map: # %{"#channel" => [:operator, :voice] defmodule Storage do def delete(id) do op(fn(ets) -> :ets.delete(ets, id) end) end def insert(tuple) do op(fn(ets) -> :ets.insert(ets, tuple) end) end def clear_network(network) do op(fn(ets) -> spec = [ {{:_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$1", {:const, network}} ], [:"$_"]} ] :ets.match_delete(ets, spec) end) end def op(fun) do GenServer.call(__MODULE__, {:op, fun}) end def start_link do GenServer.start_link(__MODULE__, [], [name: __MODULE__]) end def init([]) do ets = :ets.new(__MODULE__, [:set, :named_table, :protected, {:read_concurrency, true}]) {:ok, ets} end def handle_call({:op, fun}, _from, ets) do returned = try do {:ok, fun.(ets)} rescue rescued -> {:error, rescued} catch rescued -> {:error, rescued} end {:reply, returned, ets} end def terminate(_reason, ets) do :ok end end defmodule Id, do: use EntropyString defmodule User do defstruct [:id, :account, :network, :nick, {:nicks, []}, :username, :host, :realname, {:privileges, %{}}, {:last_active, %{}}] def to_tuple(u = %__MODULE__{}) do {u.id || IRC.UserTrack.Id.large_id, u.network, u.account, String.downcase(u.nick), u.nick, u.nicks || [], u.username, u.host, u.realname, u.privileges, u.last_active} end #tuple size: 11 def from_tuple({id, network, account, _downcased_nick, nick, nicks, username, host, realname, privs, last_active}) do struct = %__MODULE__{id: id, account: account, network: network, nick: nick, nicks: nicks, username: username, host: host, realname: realname, privileges: privs, last_active: last_active} end end def find_by_account(%IRC.Account{id: id}) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$2", :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$2", {:const, id}} ], [:"$_"]} ] for obj <- :ets.select(@ets, spec), do: User.from_tuple(obj) end def find_by_account(network, nil) do nil end def find_by_account(network, %IRC.Account{id: id}) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :"$1", :"$2", :_, :_, :_, :_, :_, :_, :_, :_}, [ {:andalso, {:==, :"$1", {:const, network}}, {:==, :"$2", {:const, id}}} ], [:"$_"]} ] case :ets.select(@ets, spec) do results = [_r | _] -> results |> Enum.sort_by(fn({_, _, _, _, _, _, _, _, _, _, actives}) -> Map.get(actives, nil) end, {:desc, NaiveDateTime}) |> List.first |> User.from_tuple() _ -> nil end end def clear_network(network) do Storage.clear_network(network) end def merge_account(old_id, new_id) do #iex(15)> :ets.fun2ms(fn(obj = {_, net, acct, _, _, _, _, _, _}) when net == network and acct == account -> obj end) spec = [ {{:_, :_, :"$1", :_, :_, :_, :_, :_, :_, :_, :_}, [ {:==, :"$1", {:const, old_id}} ], [:"$_"]} ] Enum.each(:ets.select(@ets, spec), fn({id, net, _, downcased_nick, nick, nicks, username, host, realname, privs, active}) -> Storage.op(fn(ets) -> :ets.insert(@ets, {id, net, new_id, downcased_nick, nick, nicks, username, host, realname, privs, active}) end) end) end def find_by_nick(%ExIRC.Who{network: network, nick: nick}) do find_by_nick(network, nick) end def find_by_nick(%ExIRC.SenderInfo{network: network, nick: nick}) do find_by_nick(network, nick) end def find_by_nick(network, nick) do case :ets.match(@ets, {:"$1", network, :_, String.downcase(nick), :_, :_, :_, :_, :_, :_, :_}) do [[id]] -> lookup(id) _ -> nil end end def to_list, do: :ets.tab2list(@ets) def lookup(id) do case :ets.lookup(@ets, id) do [] -> nil [tuple] -> User.from_tuple(tuple) end end def operator?(network, channel, nick) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel, []) Enum.member?(privs, :admin) || Enum.member?(privs, :operator) else false end end def channel(network, channel) do Enum.filter(to_list(), fn({_, network, _, _, _, _, _, _, _, channels, _}) -> Map.get(channels, channel) end) end # TODO def connected(sender = %{nick: nick}) do end def joined(c, s), do: joined(c,s,[]) def joined(channel, sender=%{nick: nick, user: uname, host: host}, privileges, touch \\ true) do privileges = if IRC.admin?(sender) do privileges ++ [:admin] else privileges end user = if user = find_by_nick(sender.network, nick) do %User{user | username: uname, host: host, privileges: Map.put(user.privileges || %{}, channel, privileges)} else - user = %User{network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} + user = %User{id: IRC.UserTrack.Id.large_id, network: sender.network, nick: nick, username: uname, host: host, privileges: %{channel => privileges}} account = IRC.Account.lookup(user).id user = %User{user | account: account} end user = touch_struct(user, channel) if touch && user.account do IRC.Membership.touch(user.account, sender.network, channel) end Storage.op(fn(ets) -> :ets.insert(ets, User.to_tuple(user)) end) + + IRC.Connection.publish_event({sender.network, channel}, %{type: :join, user_id: user.id, account_id: user.account}) end #def joined(network, channel, nick, privileges) do # user = if user = find_by_nick(network, nick) do # %User{user | privileges: Map.put(user.privileges, channel, privileges)} # else # %User{nick: nick, privileges: %{channel => privileges}} # end # # Storage.op(fn(ets) -> # :ets.insert(ets, User.to_tuple(user)) # end) #end def messaged(%IRC.Message{network: network, account: account, channel: chan, sender: %{nick: nick}} = m) do {user, account} = if user = find_by_nick(network, nick) do {touch_struct(user, chan), account || IRC.Account.lookup(user)} else user = %User{network: network, nick: nick, privileges: %{}} account = IRC.Account.lookup(user) {%User{user | account: account.id}, account} end Storage.insert(User.to_tuple(user)) if chan, do: IRC.Membership.touch(account, network, chan) if !m.account do {:ok, %IRC.Message{m | account: account}} else :ok end end def renamed(network, old_nick, new_nick) do if user = find_by_nick(network, old_nick) do old_account = IRC.Account.lookup(user) user = %User{user | nick: new_nick, nicks: [old_nick|user.nicks]} account = IRC.Account.lookup(user, false) || old_account user = %User{user | nick: new_nick, account: account.id, nicks: [old_nick|user.nicks]} Storage.insert(User.to_tuple(user)) + channels = for {channel, _} <- user.privileges, do: channel + IRC.Connection.publish_event(network, %{type: :nick, user_id: user.id, account_id: account.id, nick: new_nick, old_nick: old_nick}) end end def change_privileges(network, channel, nick, {add, remove}) do if user = find_by_nick(network, nick) do privs = Map.get(user.privileges, channel) privs = Enum.reduce(add, privs, fn(priv, acc) -> [priv|acc] end) privs = Enum.reduce(remove, privs, fn(priv, acc) -> List.delete(acc, priv) end) user = %User{user | privileges: Map.put(user.privileges, channel, privs)} Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :privileges, user_id: user.id, account_id: user.account, added: add, removed: remove}) end end + # XXX: Reason def parted(channel, %{network: network, nick: nick}) do parted(network, channel, nick) end def parted(network, channel, nick) do if user = find_by_nick(network, nick) do if user.account do IRC.Membership.touch(user.account, network, channel) end privs = Map.delete(user.privileges, channel) lasts = Map.delete(user.last_active, channel) if Enum.count(privs) > 0 do user = %User{user | privileges: privs} Storage.insert(User.to_tuple(user)) + IRC.Connection.publish_event({network, channel}, %{type: :part, user_id: user.id, account_id: user.account, reason: nil}) else + IRC.Connection.publish_event(network, %{type: :quit, user_id: user.id, account_id: user.account, reason: "Left all known channels"}) Storage.delete(user.id) end end end - def quitted(sender) do + def quitted(sender, reason) do if user = find_by_nick(sender.network, sender.nick) do if user.account do - for({channel, _} <- user.privileges, do: IRC.Membership.touch(user.account, sender.network, channel)) + for {channel, _} <- user.privileges do + IRC.Membership.touch(user.account, sender.network, channel) + end + IRC.Connection.publish_event(sender.network, %{type: :quit, user_id: user.id, account_id: user.account, reason: reason}) end Storage.delete(user.id) end end defp touch_struct(user = %User{last_active: last_active}, channel) do now = NaiveDateTime.utc_now() last_active = last_active |> Map.put(channel, now) |> Map.put(nil, now) %User{user | last_active: last_active} end + defp userchans(%{privileges: privileges}) do + for({chan, _} <- privileges, do: chan) + end + end diff --git a/lib/lsg/telegram.ex b/lib/lsg/telegram.ex index e8758e3..63940dc 100644 --- a/lib/lsg/telegram.ex +++ b/lib/lsg/telegram.ex @@ -1,232 +1,232 @@ defmodule LSG.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) Telegram.Bot.ChatBot.Chat.Session.Supervisor.start_child(LSG.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} = LSG.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} = LSG.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(LSG.GenMagic, {:bytes, smol_body}), bucket = Application.get_env(:lsg, :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 = LSGWeb.Router.Helpers.url(LSGWeb.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} = LSG.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{ 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, ["message:private", "message:telegram"]) + 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/telegram_room.ex b/lib/lsg/telegram_room.ex index 9504cd4..1eeec8f 100644 --- a/lib/lsg/telegram_room.ex +++ b/lib/lsg/telegram_room.ex @@ -1,116 +1,120 @@ defmodule LSG.TelegramRoom do require Logger @behaviour Telegram.ChatBot alias Telegram.Api @impl Telegram.ChatBot def init(id) do token = Keyword.get(Application.get_env(:lsg, :telegram, []), :key) {:ok, chat} = Api.request(token, "getChat", chat_id: id) Logger.debug("Starting ChatBot for room #{id} \"#{chat["title"]}\"") [net, chan] = String.split(chat["title"], "/", parts: 2) case IRC.Connection.get_network(net, chan) do %IRC.Connection{} -> :global.register_name({__MODULE__, net, chan}, self()) - {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:message", plugin: __MODULE__) + {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:messages", plugin: __MODULE__) {:ok, _} = Registry.register(IRC.PubSub, "#{net}/#{chan}:triggers", plugin: __MODULE__) err -> Logger.warn("Did not found telegram match for #{id} \"#{chat["title"]}\"") end {:ok, %{id: id, net: net, chan: chan}} end def handle_update(%{"message" => %{"from" => %{"id" => user_id}, "text" => text}}, _token, state) do account = IRC.Account.find_meta_account("telegram-id", user_id) 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" => %{"id" => user_id}, "location" => %{"latitude" => lat, "longitude" => lon}}}, _token, state) do account = IRC.Account.find_meta_account("telegram-id", user_id) 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, _, %IRC.Message{sender: %{nick: nick}, text: text}}, state) do LSG.Telegram.send_message(state.id, "<#{nick}> #{text}") {:ok, state} end def handle_info({:raw, lines}, state) when is_list(lines) do formatted = for l <- lines, into: <<>>, do: l <> "\n" LSG.Telegram.send_message(state.id, formatted) {:ok, state} end def handle_info({:raw, line}, state) do handle_info({:raw, [line]}, state) end def handle_info(info, state) do {:ok, state} end defp upload(_type, %{"message" => m = %{"chat" => %{"id" => chat_id}, "from" => %{"id" => user_id}}}, token, state) do account = IRC.Account.find_meta_account("telegram-id", user_id) 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(LSG.GenMagic, {:bytes, smol_body}), bucket = Application.get_env(:lsg, :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 = LSGWeb.Router.Helpers.url(LSGWeb.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_irc/correction_plugin.ex b/lib/lsg_irc/correction_plugin.ex index f370cf8..a77c4a2 100644 --- a/lib/lsg_irc/correction_plugin.ex +++ b/lib/lsg_irc/correction_plugin.ex @@ -1,59 +1,59 @@ defmodule LSG.IRC.CorrectionPlugin do @moduledoc """ # correction * `s/pattern/replace` replace `pattern` by `replace` in the last matching message """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init(_) do - {:ok, _} = Registry.register(IRC.PubSub, "message", [plugin: __MODULE__]) + {:ok, _} = Registry.register(IRC.PubSub, "messages", [plugin: __MODULE__]) {:ok, _} = Registry.register(IRC.PubSub, "triggers", [plugin: __MODULE__]) {:ok, %{}} end # Trigger fallback def handle_info({:irc, :trigger, _, m = %IRC.Message{}}, state) do {:noreply, correction(m, state)} end def handle_info({:irc, :text, m = %IRC.Message{}}, state) do {:noreply, correction(m, state)} end def correction(m, state) do history = Map.get(state, key(m), []) if String.starts_with?(m.text, "s/") do case String.split(m.text, "/") do ["s", match, replace | _] -> case Regex.compile(match) do {:ok, reg} -> repl = Enum.find(history, fn(m) -> Regex.match?(reg, m.text) end) if repl do new_text = String.replace(repl.text, reg, replace) m.replyfun.("correction: <#{repl.sender.nick}> #{new_text}") end _ -> m.replyfun.("correction: invalid regex") end _ -> m.replyfun.("correction: invalid regex format") end state else history = if length(history) > 100 do {_, history} = List.pop_at(history, 99) [m | history] else [m | history] end Map.put(state, key(m), history) end end defp key(%{network: net, channel: chan}), do: "#{net}/#{chan}" end diff --git a/lib/lsg_irc/last_fm_plugin.ex b/lib/lsg_irc/last_fm_plugin.ex index 04f887c..0c6b8a6 100644 --- a/lib/lsg_irc/last_fm_plugin.ex +++ b/lib/lsg_irc/last_fm_plugin.ex @@ -1,188 +1,187 @@ defmodule LSG.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 = (LSG.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 + _ -> 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(username) do + 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] 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] 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 0c785a0..3d657ad 100644 --- a/lib/lsg_irc/link_plugin.ex +++ b/lib/lsg_irc/link_plugin.ex @@ -1,266 +1,266 @@ defmodule LSG.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, LSG.IRC.LinkPlugin, handlers: [ LSG.IRC.LinkPlugin.Youtube: [ invidious: true ], LSG.IRC.LinkPlugin.Twitter: [], LSG.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, "message", [plugin: __MODULE__]) - #{:ok, _} = Registry.register(IRC.PubSub, "message:telegram", [plugin: __MODULE__]) + {: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 handlers = Keyword.get(Application.get_env(:lsg, __MODULE__, [handlers: []]), :handlers) handler = Enum.reduce_while(handlers, nil, fn({module, opts}, acc) -> 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 case module.expand(uri, params, opts) do {:ok, data} -> {:ok, acc, data} :error -> expand_default(acc) :skip -> nil end rescue e -> Logger.error(inspect(e)) expand_default(acc) catch e, b -> Logger.error(inspect({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) 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 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/youtube.ex b/lib/lsg_irc/link_plugin/youtube.ex index fd0f1b4..536cab6 100644 --- a/lib/lsg_irc/link_plugin/youtube.ex +++ b/lib/lsg_irc/link_plugin/youtube.ex @@ -1,73 +1,73 @@ defmodule LSG.IRC.LinkPlugin.YouTube do @behaviour LSG.IRC.LinkPlugin @moduledoc """ # YouTube link preview needs an API key: ``` config :lsg, :youtube, api_key: "xxxxxxxxxxxxx" ``` options: * `invidious`: Add a link to invidious. Default: "yewtu.be". """ @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] 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 Keyword.get(opts, :invidious, "yewtu.be") do - ["-> https://#{}host}/watch?v=#{video_id}"] + line = if host = Keyword.get(opts, :invidious, "yewtu.be") 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," <> " #{item["statistics"]["dislikeCount"]} dislikes"]} else :error end _ -> :error end end end end diff --git a/lib/lsg_irc/outline_plugin.ex b/lib/lsg_irc/outline_plugin.ex index d28d0d0..47fa6fa 100644 --- a/lib/lsg_irc/outline_plugin.ex +++ b/lib/lsg_irc/outline_plugin.ex @@ -1,108 +1,108 @@ defmodule LSG.IRC.OutlinePlugin do @moduledoc """ # outline auto-link Envoie un lien vers Outline quand un lien est envoyé. * **!outline ``** crée un lien outline pour ``. * **+outline ``** active outline pour ``. * **-outline ``** désactive outline pour ``. """ def short_irc_doc, do: false def irc_doc, do: @moduledoc require Logger def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end defstruct [:file, :hosts] def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:outline", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "message", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) file = Path.join(LSG.data_path, "/outline.txt") hosts = case File.read(file) do {:error, :enoent} -> [] {:ok, lines} -> String.split(lines, "\n", trim: true) end {:ok, %__MODULE__{file: file, hosts: hosts}} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :plus, args: [host]}}}, state) do state = %{state | hosts: [host | state.hosts]} save(state) message.replyfun.("ok") {:noreply, state} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :minus, args: [host]}}}, state) do state = %{state | hosts: List.delete(state.hosts, host)} save(state) message.replyfun.("ok") {:noreply, state} end def handle_info({:irc, :trigger, "outline", message = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [url]}}}, state) do line = "-> #{outline(url)}" message.replyfun.(line) end def handle_info({:irc, :text, message = %IRC.Message{text: text}}, state) do String.split(text) |> Enum.map(fn(word) -> if String.starts_with?(word, "http://") || String.starts_with?(word, "https://") do uri = URI.parse(word) if uri.scheme && uri.host do if Enum.any?(state.hosts, fn(host) -> String.ends_with?(uri.host, host) end) do outline_url = outline(word) line = "-> #{outline_url}" message.replyfun.(line) end end end end) {:noreply, state} end def handle_info(msg, state) do {:noreply, state} end def save(state = %{file: file, hosts: hosts}) do string = Enum.join(hosts, "\n") File.write(file, string) end def outline(url) do unexpanded = "https://outline.com/#{url}" headers = [ {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:77.0) Gecko/20100101 Firefox/77.0"}, {"Accept", "*/*"}, {"Accept-Language", "en-US,en;q=0.5"}, {"Origin", "https://outline.com"}, {"DNT", "1"}, {"Referer", unexpanded}, {"Pragma", "no-cache"}, {"Cache-Control", "no-cache"} ] params = %{"source_url" => url} case HTTPoison.get("https://api.outline.com/v3/parse_article", headers, params: params) do {:ok, %HTTPoison.Response{status_code: 200, body: json}} -> body = Poison.decode!(json) if Map.get(body, "success") do code = get_in(body, ["data", "short_code"]) "https://outline.com/#{code}" else unexpanded end error -> Logger.info("outline.com error: #{inspect error}") unexpanded end end end diff --git a/lib/lsg_irc/preums_plugin.ex b/lib/lsg_irc/preums_plugin.ex index 7bb2c78..68257f0 100644 --- a/lib/lsg_irc/preums_plugin.ex +++ b/lib/lsg_irc/preums_plugin.ex @@ -1,276 +1,276 @@ defmodule LSG.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 (LSG.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, "message", 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, LSG.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/say_plugin.ex b/lib/lsg_irc/say_plugin.ex index 085ca92..8e93ec2 100644 --- a/lib/lsg_irc/say_plugin.ex +++ b/lib/lsg_irc/say_plugin.ex @@ -1,73 +1,73 @@ defmodule LSG.IRC.SayPlugin do def irc_doc do """ # say Say something... * **!say `` ``** say something on `channel` * **!asay `` ``** same but anonymously You must be a member of the channel. """ end def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [type: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "trigger:say", regopts) {:ok, _} = Registry.register(IRC.PubSub, "trigger:asay", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "message:private", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", regopts) {:ok, nil} end def handle_info({:irc, :trigger, "say", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, true) {:noreply, state} end def handle_info({:irc, :trigger, "asay", m = %{trigger: %{type: :bang, args: [target | text]}}}, state) do text = Enum.join(text, " ") say_for(m.account, target, text, false) {:noreply, state} end def handle_info({:irc, :text, m = %{text: "say "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, true) _ -> nil end {:noreply, state} end def handle_info({:irc, :text, m = %{text: "asay "<>rest}}, state) do case String.split(rest, " ", parts: 2) do [target, text] -> say_for(m.account, target, text, false) _ -> nil end {:noreply, state} end def handle_info(_, state) do {:noreply, state} end defp say_for(account, target, text, with_nick?) do for {net, chan} <- IRC.Membership.of_account(account) do chan2 = String.replace(chan, "#", "") if (target == "#{net}/#{chan}" || target == "#{net}/#{chan2}" || target == chan || target == chan2) do if with_nick? do IRC.send_message_as(account, net, chan, text) else IRC.Connection.broadcast_message(net, chan, text) end end end end end diff --git a/lib/lsg_irc/seen_plugin.ex b/lib/lsg_irc/seen_plugin.ex index f1a5473..405c372 100644 --- a/lib/lsg_irc/seen_plugin.ex +++ b/lib/lsg_irc/seen_plugin.ex @@ -1,59 +1,59 @@ defmodule LSG.IRC.SeenPlugin do @moduledoc """ # seen * **!seen ``** """ def irc_doc, do: @moduledoc def start_link() do GenServer.start_link(__MODULE__, [], name: __MODULE__) end def init([]) do regopts = [plugin: __MODULE__] {:ok, _} = Registry.register(IRC.PubSub, "triggers", regopts) - {:ok, _} = Registry.register(IRC.PubSub, "message", regopts) + {:ok, _} = Registry.register(IRC.PubSub, "messages", regopts) dets_filename = (LSG.data_path() <> "/seen.dets") |> String.to_charlist() {:ok, dets} = :dets.open_file(dets_filename, []) {:ok, %{dets: dets}} end def handle_info({:irc, :trigger, "seen", m = %IRC.Message{trigger: %IRC.Trigger{type: :bang, args: [nick]}}}, state) do witness(m, state) m.replyfun.(last_seen(m.channel, nick, state)) {:noreply, state} end def handle_info({:irc, :trigger, _, m}, state) do witness(m, state) {:noreply, state} end def handle_info({:irc, :text, m}, state) do witness(m, state) {:noreply, state} end defp witness(%IRC.Message{channel: channel, text: text, sender: %{nick: nick}}, %{dets: dets}) do :dets.insert(dets, {{channel, nick}, DateTime.utc_now(), text}) :ok end defp last_seen(channel, nick, %{dets: dets}) do case :dets.lookup(dets, {channel, nick}) do [{_, date, text}] -> diff = round(DateTime.diff(DateTime.utc_now(), date)/60) cond do diff >= 30 -> duration = Timex.Duration.from_minutes(diff) format = Timex.Format.Duration.Formatter.lformat(duration, "fr", :humanized) "#{nick} a parlé pour la dernière fois il y a #{format}: “#{text}”" true -> "#{nick} est là..." end [] -> "je ne connais pas de #{nick}" end end end diff --git a/lib/lsg_irc/sms_plugin.ex b/lib/lsg_irc/sms_plugin.ex index a37fb4e..b183f7d 100644 --- a/lib/lsg_irc/sms_plugin.ex +++ b/lib/lsg_irc/sms_plugin.ex @@ -1,164 +1,164 @@ defmodule LSG.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{ 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) } IO.puts("converted sms to message: #{inspect message}") - IRC.Connection.publish(message, ["message:sms"]) + IRC.Connection.publish(message, ["messages:sms"]) message end end def my_number() do Keyword.get(Application.get_env(:lsg, :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) "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" =>LSGWeb.Router.Helpers.sms_url(LSGWeb.Endpoint, :ovh_callback), "smsResponse" => %{ "cgiUrl" => LSGWeb.Router.Helpers.sms_url(LSGWeb.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) end defp env(key) do Keyword.get(env(), key) end end diff --git a/lib/lsg_web.ex b/lib/lsg_web.ex index 113d00d..eb0cdc5 100644 --- a/lib/lsg_web.ex +++ b/lib/lsg_web.ex @@ -1,90 +1,93 @@ defmodule LSGWeb do @moduledoc """ The entrypoint for defining your web interface, such as controllers, views, channels and so on. This can be used in your application as: use LSGWeb, :controller use LSGWeb, :view The definitions below will be executed for every view, controller, etc, so keep them short and clean, focused on imports, uses and aliases. Do NOT define functions inside the quoted expressions below. Instead, define any helper function in modules and import those modules here. """ def format_chan("##") do "♯♯" end def format_chan("#") do "♯" end def format_chan("#"<>chan) do chan end def reformat_chan("♯") do "#" end def reformat_chan("♯♯") do "##" end def reformat_chan(chan) do "#"<>chan end def controller do quote do use Phoenix.Controller, namespace: LSGWeb import Plug.Conn import LSGWeb.Router.Helpers import LSGWeb.Gettext end end def view do quote do use Phoenix.View, root: "lib/lsg_web/templates", namespace: LSGWeb # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 2, view_module: 1] # Use all HTML functionality (forms, tags, etc) use Phoenix.HTML import LSGWeb.Router.Helpers import LSGWeb.ErrorHelpers import LSGWeb.Gettext + + import Phoenix.LiveView.Helpers end end def router do quote do use Phoenix.Router import Plug.Conn import Phoenix.Controller + import Phoenix.LiveView.Router end end def channel do quote do use Phoenix.Channel import LSGWeb.Gettext end end @doc """ When used, dispatch to the appropriate controller/view/etc. """ defmacro __using__(which) when is_atom(which) do apply(__MODULE__, which, []) end end diff --git a/lib/lsg_web/components/component.ex b/lib/lsg_web/components/component.ex new file mode 100644 index 0000000..37d75e3 --- /dev/null +++ b/lib/lsg_web/components/component.ex @@ -0,0 +1,40 @@ +defmodule LSGWeb.Component do + use Phoenix.Component + + @date_time_default_format "%F %H:%M" + @date_time_formats %{"time-24-with-seconds" => "%H:%M:%S"} + def naive_date_time_utc(assigns = %{format: format}) do + assigns = assign(assigns, :format, Map.get(@date_time_formats, format, format)) + ~H""" + + """ + end + def naive_date_time_utc(assigns) do + naive_date_time_utc(assign(assigns, :format, "%F %H:%M")) + end + def get_luxon_format("%H:%M:%S"), do: "TIME_24_WITH_SECONDS" + + def nick(assigns = %{self: false}) do + ~H""" + + <%= @nick %> + + """ + end + + def nick(assigns = %{self: true}) do + ~H""" + + You + + """ + end + + +end diff --git a/lib/lsg_web/components/event_component.ex b/lib/lsg_web/components/event_component.ex new file mode 100644 index 0000000..3b9cd3b --- /dev/null +++ b/lib/lsg_web/components/event_component.ex @@ -0,0 +1,36 @@ +defmodule LSGWeb.EventComponent do + use Phoenix.Component + + def content(assigns = %{event: %{type: :quit}}) do + ~H""" + + has quit: + <%= @reason %> + """ + end + + def content(assigns = %{event: %{type: :part}}) do + ~H""" + + has left: + <%= @reason %> + """ + end + + def content(assigns = %{event: %{type: :nick}}) do + ~H""" + <%= @old_nick %> + is now known as + + """ + end + + def content(assigns = %{event: %{type: :join}}) do + ~H""" + + joined + """ + end + + +end diff --git a/lib/lsg_web/components/message_component.ex b/lib/lsg_web/components/message_component.ex new file mode 100644 index 0000000..2381411 --- /dev/null +++ b/lib/lsg_web/components/message_component.ex @@ -0,0 +1,10 @@ +defmodule LSGWeb.MessageComponent do + use Phoenix.Component + + def content(assigns) do + ~H""" +
<%= @text %>
+ """ + end + +end diff --git a/lib/lsg_web/controllers/irc_auth_sse_controller.ex b/lib/lsg_web/controllers/irc_auth_sse_controller.ex index c39a866..f370d97 100644 --- a/lib/lsg_web/controllers/irc_auth_sse_controller.ex +++ b/lib/lsg_web/controllers/irc_auth_sse_controller.ex @@ -1,66 +1,66 @@ defmodule LSGWeb.IrcAuthSseController do use LSGWeb, :controller require Logger @ping_interval 20_000 @expire_delay :timer.minutes(3) def sse(conn, params) do perks = if uri = Map.get(params, "redirect_to") do {:redirect, uri} else nil end token = String.downcase(EntropyString.random_string(65)) conn |> assign(:token, token) |> assign(:perks, perks) |> put_resp_header("X-Accel-Buffering", "no") |> put_resp_header("content-type", "text/event-stream") |> send_chunked(200) |> subscribe() |> send_sse_message("token", token) |> sse_loop end def subscribe(conn) do :timer.send_interval(@ping_interval, {:event, :ping}) :timer.send_after(@expire_delay, {:event, :expire}) - {:ok, _} = Registry.register(IRC.PubSub, "message:private", []) + {:ok, _} = Registry.register(IRC.PubSub, "messages:private", []) conn end def sse_loop(conn) do {type, event, exit} = receive do {:event, :ping} -> {"ping", "ping", false} {:event, :expire} -> {"expire", "expire", true} {:irc, :text, %{account: account, text: token} = m} -> if String.downcase(String.trim(token)) == conn.assigns.token do path = LSG.AuthToken.new_path(account.id, conn.assigns.perks) m.replyfun.("ok!") {"authenticated", path, true} else {nil, nil, false} end _ -> {nil, nil, false} end conn = if type do send_sse_message(conn, type, event) else conn end if exit do conn else sse_loop(conn) end end defp send_sse_message(conn, type, data) do {:ok, conn} = chunk(conn, "event: #{type}\ndata: #{data}\n\n") conn end end diff --git a/lib/lsg_web/endpoint.ex b/lib/lsg_web/endpoint.ex index 37f7e84..4dd8151 100644 --- a/lib/lsg_web/endpoint.ex +++ b/lib/lsg_web/endpoint.ex @@ -1,57 +1,60 @@ defmodule LSGWeb.Endpoint do use Phoenix.Endpoint, otp_app: :lsg - socket "/socket", LSGWeb.UserSocket, websocket: true - # 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, 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: Poison 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, - store: :cookie, - key: "_lsg_key", - signing_salt: "+p7K3wrj" + plug Plug.Session, @session_options plug LSGWeb.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/live/chat_live.ex b/lib/lsg_web/live/chat_live.ex new file mode 100644 index 0000000..a2b4c13 --- /dev/null +++ b/lib/lsg_web/live/chat_live.ex @@ -0,0 +1,101 @@ +defmodule LSGWeb.ChatLive do + use Phoenix.LiveView + use Phoenix.HTML + require Logger + + def mount(%{"network" => network, "chan" => chan}, %{"account" => account_id}, socket) do + chan = LSGWeb.reformat_chan(chan) + connection = IRC.Connection.get_network(network, chan) + account = IRC.Account.get(account_id) + membership = IRC.Membership.of_account(IRC.Account.get("DRgpD4fLf8PDJMLp8Dtb")) + if account && connection && Enum.member?(membership, {connection.network, chan}) do + {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}:events", plugin: __MODULE__) + for t <- ["messages", "triggers", "outputs", "events"] do + {:ok, _} = Registry.register(IRC.PubSub, "#{connection.network}/#{chan}:#{t}", plugin: __MODULE__) + end + + IRC.PuppetConnection.start(account, connection) + + users = IRC.UserTrack.channel(connection.network, chan) + |> Enum.map(fn(tuple) -> IRC.UserTrack.User.from_tuple(tuple) end) + |> Enum.reduce(Map.new, fn(user = %{id: id}, acc) -> + Map.put(acc, id, user) + end) + + socket = socket + |> assign(:connection_id, connection.id) + |> assign(:network, connection.network) + |> assign(:chan, chan) + |> assign(:title, "live") + |> assign(:channel, chan) + |> assign(:account_id, account.id) + |> assign(:backlog, []) + |> assign(:users, users) + |> assign(:counter, 0) + + {:ok, socket} + else + {:ok, redirect(socket, to: "/")} + end + end + + def handle_event("send", %{"message" => %{"text" => text}}, socket) do + account = IRC.Account.get(socket.assigns.account_id) + IRC.send_message_as(account, socket.assigns.network, socket.assigns.channel, text, true) + {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} + end + + def handle_info({:irc, :event, event = %{type: :join, user_id: id}}, socket) do + if user = IRC.UserTrack.lookup(id) do + IO.puts("JOIN USER JOIN USER JOIN USER") + + socket = socket + |> assign(:users, Map.put(socket.assigns.users, id, user)) + |> assign(:backlog, socket.assigns.backlog ++ [event]) + + IO.inspect(socket.assigns.users) + + {:noreply, socket} + else + IO.puts("\n\n\n?!\n\n\n?!\n\n\n\n") + {:noreply, socket} + end + end + + def handle_info({:irc, :event, event = %{type: :nick, user_id: id, nick: nick}}, socket) do + socket = socket + |> assign(:users, update_in(socket.assigns.users, [id, :nick], nick)) + |> assign(:backlog, socket.assigns.backlog ++ [event]) + {:noreply, socket} + end + + def handle_info({:irc, :event, event = %{type: :quit, user_id: id}}, socket) do + socket = socket + |> assign(:users, Map.delete(socket.assigns.users, id)) + |> assign(:backlog, socket.assigns.backlog ++ [event]) + {:noreply, socket} + end + + def handle_info({:irc, :event, event = %{type: :part, user_id: id}}, socket) do + socket = socket + |> assign(:users, Map.delete(socket.assigns.users, id)) + |> assign(:backlog, socket.assigns.backlog ++ [event]) + {:noreply, socket} + end + + def handle_info({:irc, :trigger, _, message}, socket) do + handle_info({:irc, nil, message}, socket) + end + + def handle_info({:irc, :text, message}, socket) do + socket = socket + |> assign(:backlog, socket.assigns.backlog ++ [message]) + {:noreply, socket} + end + + def handle_info(info, socket) do + Logger.debug("Unhandled info: #{inspect info}") + {:noreply, socket} + end + +end diff --git a/lib/lsg_web/live/chat_live.html.heex b/lib/lsg_web/live/chat_live.html.heex new file mode 100644 index 0000000..01d8b3a --- /dev/null +++ b/lib/lsg_web/live/chat_live.html.heex @@ -0,0 +1,96 @@ +
+ +
+
+

+ <%= @network %> + <%= @chan %> +

+ +
+
+ +
+ +
+ <%= if Enum.empty?(@backlog) do %> +

+ Disconnected +

+

+ Oh no error +

+ <% end %> + +
    + <%= for message <- @backlog do %> + <%= if is_map(message) && Map.get(message, :__struct__) == IRC.Message do %> +
  • + + <%= message.sender.nick %> + + + +
  • + <% end %> + + <%= if is_binary(message) do %> +
  • <%= message %>
  • + <% end %> + + <%= if is_map(message) && Map.get(message, :type) do %> +
  • + + * * * + + + +
  • + <% end %> + <% end %> +
+
+ + + +
+ + <.form let={f} id={"form-#{@counter}"} for={:message} phx-submit="send" class="w-full px-4 pt-4"> +
+
+ <%= text_input f, :text, class: "focus:ring-indigo-500 focus:border-indigo-500 block w-full border rounded-md pl-4 sm:text-sm border-gray-300", autofocus: true, 'phx-hook': "AutoFocus", autocomplete: "off", placeholder: "Don't be shy, say something…" %> + <%= submit content_tag(:span, "Send"), class: "-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500"%> +
+
+ +
diff --git a/lib/lsg_web/router.ex b/lib/lsg_web/router.ex index d681ed9..5fcf0a8 100644 --- a/lib/lsg_web/router.ex +++ b/lib/lsg_web/router.ex @@ -1,52 +1,53 @@ defmodule LSGWeb.Router do use LSGWeb, :router pipeline :browser do plug :accepts, ["html", "txt"] plug :fetch_session plug :fetch_flash - #plug :protect_from_forgery - #plug :put_secure_browser_headers + plug :fetch_live_flash + plug :protect_from_forgery + plug :put_secure_browser_headers + plug :put_root_layout, {LSGWeb.LayoutView, :root} end pipeline :api do plug :accepts, ["json", "sse"] end scope "/api", LSGWeb do pipe_through :api get "/irc-auth.sse", IrcAuthSseController, :sse post "/sms/callback/Ovh", SmsController, :ovh_callback, as: :sms end scope "/", LSGWeb do pipe_through :browser get "/", PageController, :index - get "/embed/widget", PageController, :widget - get "/api", PageController, :api get "/login/irc/:token", PageController, :token, as: :login get "/api/untappd/callback", UntappdController, :callback, as: :untappd_callback get "/-", IrcController, :index get "/-/txt", IrcController, :txt get "/-/txt/:name", IrcController, :txt get "/-/alcoolog", AlcoologController, :index get "/-/alcoolog/~/:account_name", AlcoologController, :index get "/:network", NetworkController, :index get "/:network/~:nick/alcoolog", AlcoologController, :nick get "/:network/~:nick/alcoolog/log.json", AlcoologController, :nick_log_json get "/:network/~:nick/alcoolog/gls.json", AlcoologController, :nick_gls_json get "/:network/~:nick/alcoolog/volumes.json", AlcoologController, :nick_volumes_json get "/:network/~:nick/alcoolog/history.json", AlcoologController, :nick_history_json get "/:network/~:nick/alcoolog/stats.json", AlcoologController, :nick_stats_json get "/:network/:chan/alcoolog", AlcoologController, :index get "/:network/:chan/alcoolog/gls.json", AlcoologController, :index_gls_json put "/api/alcoolog/minisync/:user_id/meta/:key", AlcoologController, :minisync_put_meta get "/:network/:chan", IrcController, :index + live "/:network/:chan/live", ChatLive get "/:network/:chan/txt", IrcController, :txt get "/:network/:chan/txt/:name", IrcController, :txt get "/:network/:channel/preums", IrcController, :preums get "/:network/:chan/alcoolog/t/:token", AlcoologController, :token end end diff --git a/lib/lsg_web/templates/irc/index.html.eex b/lib/lsg_web/templates/irc/index.html.eex index a8544b3..f20f444 100644 --- a/lib/lsg_web/templates/irc/index.html.eex +++ b/lib/lsg_web/templates/irc/index.html.eex @@ -1,44 +1,45 @@ <%= if @members != [] do %> <% users = for {_acc, user, nick} <- @members, do: if(user, do: nick, else: content_tag(:i, nick)) %> <%= for u <- Enum.intersperse(users, ", ") do %> <%= u %> <% end %> <% end %>
<%= for {identifier, help} <- @commands do %> <%= if help do %>
<%= LSGWeb.LayoutView.liquid_markdown(@conn, help) %>
<% end %> <% end %>



Légende:
entre < >: argument obligatoire,
entre [ ]: argument optionel; [1 | ]: argument optionel avec valeur par défaut.




source: git.yt/115ans/sys

diff --git a/lib/lsg_web/templates/layout/app.html.eex b/lib/lsg_web/templates/layout/app.html.eex index 9ad05a6..bca1555 100644 --- a/lib/lsg_web/templates/layout/app.html.eex +++ b/lib/lsg_web/templates/layout/app.html.eex @@ -1,144 +1,126 @@ - - - - <%= page_title(@conn) %> - - - - - - <%= Map.get(assigns, :title, "") %> - "> - - - -

<%= if n = @conn.assigns[:network] do %><%= n %> › <% end %> <%= if c = @conn.assigns[:chan] do %><%= c %> › <% end %> <%= for({name, href} <- @conn.assigns[:breadcrumbs]||[], do: [link(name, to: href), raw(" › ")]) %> <%= @conn.assigns[:title] || @conn.assigns[:chan] || @conn.assigns[:network] %>

-
-
+
+
-
+
<%= @inner_content %>
- - - diff --git a/lib/lsg_web/templates/layout/root.html.leex b/lib/lsg_web/templates/layout/root.html.leex new file mode 100644 index 0000000..6a48506 --- /dev/null +++ b/lib/lsg_web/templates/layout/root.html.leex @@ -0,0 +1,18 @@ + + + + <%= page_title(@conn) %> + + + + + + <%= Map.get(assigns, :title, "") %> + "> + <%= csrf_meta_tag() %> + + + + <%= @inner_content %> + +