diff --git a/lib/irc/command.ex b/lib/irc/command.ex index 12725b8..b0350db 100644 --- a/lib/irc/command.ex +++ b/lib/irc/command.ex @@ -1,5 +1,5 @@ ## -# Copyright (C) 2021-2022 Valentin Lorentz +# Copyright (C) 2021-2023 Valentin Lorentz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License version 3, @@ -251,6 +251,8 @@ defmodule M51.Irc.Command do """ def downgrade(command, capabilities) do + original_tags = command.tags + # downgrade echo-message command = if Enum.member?(capabilities, :echo_message) do @@ -319,6 +321,47 @@ defmodule M51.Irc.Command do nil end + %{command: "REDACT"} -> + if Enum.member?(capabilities, :message_redaction) do + command + else + sender = Map.get(original_tags, "account") + + display_name = + case Map.get(original_tags, "+draft/display-name", nil) do + dn when is_binary(dn) -> " (#{dn})" + _ -> "" + end + + tags = Map.drop(command.tags, ["+draft/display-name", "account"]) + + command = + case command do + %{params: [channel, msgid, reason]} -> + %M51.Irc.Command{ + tags: Map.put(tags, "+draft/reply", msgid), + source: "server.", + command: "NOTICE", + params: [channel, "#{sender}#{display_name} deleted an event: #{reason}"] + } + + %{params: [channel, msgid]} -> + %M51.Irc.Command{ + tags: Map.put(tags, "+draft/reply", msgid), + source: "server.", + command: "NOTICE", + params: [channel, "#{sender}#{display_name} deleted an event"] + } + + _ -> + # shouldn't happen + nil + end + + # run downgrade() recursively in order to drop the new tags if necessary + downgrade(command, capabilities) + end + %{command: "TAGMSG"} -> if Enum.member?(capabilities, :message_tags) do command diff --git a/lib/irc/handler.ex b/lib/irc/handler.ex index 8398eb3..8e6bf03 100644 --- a/lib/irc/handler.ex +++ b/lib/irc/handler.ex @@ -72,6 +72,9 @@ defmodule M51.IrcConn.Handler do # https://ircv3.net/specs/extensions/multiline "draft/multiline" => {:multiline, "max-bytes=#{@multiline_max_bytes}"}, + # https://github.com/progval/ircv3-specifications/blob/redaction/extensions/message-redaction.md + "draft/message-redaction" => {:message_redaction, nil}, + # https://ircv3.net/specs/extensions/sasl-3.1 "sasl" => {:sasl, "PLAIN"}, @@ -903,6 +906,27 @@ defmodule M51.IrcConn.Handler do {"TAGMSG", _} -> send_needmoreparams.() + {"REDACT", [channel, targetmsgid, reason | _]} -> + send_redact( + sup_pid, + channel, + Map.get(command.tags, "label"), + targetmsgid, + reason + ) + + {"REDACT", [channel, targetmsgid | _]} -> + send_redact( + sup_pid, + channel, + Map.get(command.tags, "label"), + targetmsgid, + nil + ) + + {"REDACT", _} -> + send_needmoreparams.() + {"CHATHISTORY", ["TARGETS", _ts1, _ts2, _limit | _]} -> # This is mainly used for PMs, and we don't support those yet; so there # is little point in storing state to actually implement it @@ -1374,6 +1398,61 @@ defmodule M51.IrcConn.Handler do end end + defp send_redact(sup_pid, channel, label, targetmsgid, reason) do + writer = M51.IrcConn.Supervisor.writer(sup_pid) + matrix_client = M51.IrcConn.Supervisor.matrix_client(sup_pid) + matrix_state = M51.IrcConn.Supervisor.matrix_state(sup_pid) + send = fn cmd -> M51.IrcConn.Writer.write_command(writer, cmd) end + + # If the client provided a label, use it as txnId on Matrix's side. + # This way we can parse it when receiving the echo from Matrix's event + # stream instead of storing state. + # Otherwise, generate a random transaction id. + + nicklist = + case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do + {_room_id, room} -> room.members |> Map.keys() + nil -> [] + end + + reason = + case reason do + nil -> + nil + + reason -> + {reason, _formatted_reason} = M51.Format.irc2matrix(reason, nicklist) + reason + end + + result = + M51.MatrixClient.Client.send_redact( + matrix_client, + channel, + label, + targetmsgid, + reason + ) + + case result do + {:ok, _event_id} -> + nil + + {:error, error} -> + send.(%M51.Irc.Command{ + source: "server.", + command: "FAIL", + params: [ + "REDACT", + "UNKNOWN_ERROR", + channel, + targetmsgid, + "Error while redacting message: " <> Kernel.inspect(error) + ] + }) + end + end + defp close_connection(sup_pid) do writer = M51.IrcConn.Supervisor.writer(sup_pid) M51.IrcConn.Writer.close(writer) diff --git a/lib/matrix_client/client.ex b/lib/matrix_client/client.ex index ff35fe7..6b78566 100644 --- a/lib/matrix_client/client.ex +++ b/lib/matrix_client/client.ex @@ -296,6 +296,42 @@ defmodule M51.MatrixClient.Client do end end + @impl true + def handle_call({:send_redact, channel, label, event_id, reason}, _from, state) do + %M51.MatrixClient.Client{ + state: :connected, + irc_pid: irc_pid, + raw_client: raw_client + } = state + + matrix_state = M51.IrcConn.Supervisor.matrix_state(irc_pid) + + transaction_id = label_to_transaction_id(label) + + reply = + case M51.MatrixClient.State.room_from_irc_channel(matrix_state, channel) do + nil -> + {:reply, {:error, {:room_not_found, channel}}, state} + + {room_id, _room} -> + path = + "/_matrix/client/r0/rooms/#{urlquote(room_id)}/redact/#{urlquote(event_id)}/#{transaction_id}" + + body = + case reason do + reason when is_binary(reason) -> Jason.encode!(%{"reason" => reason}) + _ -> Jason.encode!({}) + end + + case M51.Matrix.RawClient.put(raw_client, path, body) do + {:ok, %{"event_id" => event_id}} -> {:ok, event_id} + {:error, error} -> {:error, error} + end + end + + {:reply, reply, state} + end + @impl true def handle_call({:get_event_context, channel, event_id, limit}, _from, state) do %M51.MatrixClient.Client{ @@ -543,13 +579,23 @@ defmodule M51.MatrixClient.Client do @doc """ Sends the given event object. - If 'label' is not nil, it will be passed as a 'label' message tagt when + If 'label' is not nil, it will be passed as a 'label' message tag when the event is seen in the event stream. """ def send_event(pid, channel, label, event_type, event) do GenServer.call(pid, {:send_event, channel, event_type, label, event}, @timeout) end + @doc """ + Asks the server to redact the event with the given id + + If 'label' is not nil, it will be passed as a 'label' message tag when + the event is seen in the event stream. + """ + def send_redact(pid, channel, label, event_id, reason) do + GenServer.call(pid, {:send_redact, channel, label, event_id, reason}, @timeout) + end + @doc """ Returns events that happened just before or after the specified event_id. diff --git a/lib/matrix_client/poller.ex b/lib/matrix_client/poller.ex index 9864a7b..07d4c24 100644 --- a/lib/matrix_client/poller.ex +++ b/lib/matrix_client/poller.ex @@ -1,5 +1,5 @@ ## -# Copyright (C) 2021-2022 Valentin Lorentz +# Copyright (C) 2021-2023 Valentin Lorentz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License version 3, @@ -884,38 +884,41 @@ defmodule M51.MatrixClient.Poller do member = M51.MatrixClient.State.room_member(state, room_id, sender) send = make_send_function(sup_pid, event, write) - reason = - case event["content"] do - %{"reason" => reason} when is_binary(reason) -> ": #{reason}" - _ -> "" - end + tags = %{"account" => sender} # TODO: dedup this with m.reaction handler - display_name = + tags = case member do %M51.Matrix.RoomMember{display_name: display_name} when display_name != nil -> - " (#{display_name})" + Map.put(tags, "+draft/display-name", display_name) _ -> - "" + tags end - tags = - case event do - %{"redacts" => redacts_id} - when is_binary(redacts_id) -> - %{"+draft/reply" => redacts_id} + case event do + %{"redacts" => redacts_id} when is_binary(redacts_id) -> + case event["content"] do + %{"reason" => reason} when is_binary(reason) -> + send.(%M51.Irc.Command{ + tags: tags, + source: nick2nuh(sender), + command: "REDACT", + params: [channel, redacts_id, reason] + }) - _ -> - %{} - end + _ -> + send.(%M51.Irc.Command{ + tags: tags, + source: nick2nuh(sender), + command: "REDACT", + params: [channel, redacts_id] + }) + end - send.(%M51.Irc.Command{ - tags: tags, - source: "server.", - command: "NOTICE", - params: [channel, "#{sender}#{display_name} deleted an event#{reason}"] - }) + _ -> + nil + end end def handle_event( diff --git a/test/irc/handler_test.exs b/test/irc/handler_test.exs index a413726..e8f5091 100644 --- a/test/irc/handler_test.exs +++ b/test/irc/handler_test.exs @@ -18,8 +18,8 @@ defmodule M51.IrcConn.HandlerTest do use ExUnit.Case, async: false doctest M51.IrcConn.Handler - @cap_ls_302 ":server. CAP * LS :account-tag batch draft/account-registration=before-connect draft/channel-rename draft/chathistory draft/multiline=max-bytes=8192 draft/sasl-ir echo-message extended-join labeled-response message-tags sasl=PLAIN server-time soju.im/account-required standard-replies userhost-in-names\r\n" - @cap_ls ":server. CAP * LS :account-tag batch draft/account-registration draft/channel-rename draft/chathistory draft/multiline draft/sasl-ir echo-message extended-join labeled-response message-tags sasl server-time soju.im/account-required standard-replies userhost-in-names\r\n" + @cap_ls_302 ":server. CAP * LS :account-tag batch draft/account-registration=before-connect draft/channel-rename draft/chathistory draft/message-redaction draft/multiline=max-bytes=8192 draft/sasl-ir echo-message extended-join labeled-response message-tags sasl=PLAIN server-time soju.im/account-required standard-replies userhost-in-names\r\n" + @cap_ls ":server. CAP * LS :account-tag batch draft/account-registration draft/channel-rename draft/chathistory draft/message-redaction draft/multiline draft/sasl-ir echo-message extended-join labeled-response message-tags sasl server-time soju.im/account-required standard-replies userhost-in-names\r\n" @isupport "CASEMAPPING=rfc3454 CLIENTTAGDENY=*,-draft/react,-draft/reply CHANLIMIT= CHANTYPES=#! CHATHISTORY=100 MAXTARGETS=1 MSGREFTYPES=msgid PREFIX= TARGMAX=JOIN:1,PART:1 UTF8ONLY :are supported by this server\r\n" setup do @@ -1078,4 +1078,20 @@ defmodule M51.IrcConn.HandlerTest do assert_line("BATCH :-#{batch_id}\r\n") end + + test "redact a message for no reason", %{handler: handler} do + do_connection_registration(handler) + + send(handler, cmd("REDACT #existing_room:example.org $event1")) + + assert_message({:send_redact, "#existing_room:example.org", nil, "$event1", nil}) + end + + test "redact a message for a reason", %{handler: handler} do + do_connection_registration(handler) + + send(handler, cmd("REDACT #existing_room:example.org $event1 :spam")) + + assert_message({:send_redact, "#existing_room:example.org", nil, "$event1", "spam"}) + end end diff --git a/test/matrix_client/poller_test.exs b/test/matrix_client/poller_test.exs index facc10e..3885fca 100644 --- a/test/matrix_client/poller_test.exs +++ b/test/matrix_client/poller_test.exs @@ -1,5 +1,5 @@ ## -# Copyright (C) 2021-2022 Valentin Lorentz +# Copyright (C) 2021-2023 Valentin Lorentz # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License version 3, @@ -2288,7 +2288,7 @@ defmodule M51.MatrixClient.PollerTest do }) assert_line(":nick:example.org!nick@example.org PRIVMSG #test:example.org :first message\r\n") - # m.room.redaction not implemented yet, so it's just ignored + # We'll probably see the m.room.redaction message later, so we can simply ignore this one. assert_line( ":nick:example.org!nick@example.org PRIVMSG #test:example.org :second message\r\n" ) @@ -2297,6 +2297,71 @@ defmodule M51.MatrixClient.PollerTest do end test "message redaction" do + M51.IrcConn.State.add_capabilities(:process_ircconn_state, [ + :multiline, + :message_tags, + :message_redaction + ]) + + joined_room() + + timeline_events = [ + %{ + "content" => %{"body" => "first message", "msgtype" => "m.text"}, + "event_id" => "$event1", + "origin_server_ts" => 1_632_946_233_579, + "sender" => "@nick:example.org", + "type" => "m.room.message", + "unsigned" => %{} + }, + %{ + "content" => %{}, + "event_id" => "$event2", + "redacts" => "$event1", + "origin_server_ts" => 1_633_808_172_505, + "sender" => "@admin:example.org", + "type" => "m.room.redaction", + "unsigned" => %{} + }, + %{ + "content" => %{ + "reason" => "Redacting again!" + }, + "event_id" => "$event3", + "redacts" => "$event1", + "origin_server_ts" => 1_633_808_172_505, + "sender" => "@admin:example.org", + "type" => "m.room.redaction", + "unsigned" => %{} + } + ] + + M51.MatrixClient.Poller.handle_events(self(), false, %{ + "rooms" => %{ + "join" => %{ + "!testid:example.org" => %{ + "timeline" => %{"events" => timeline_events} + } + } + } + }) + + assert_line( + "@msgid=$event1 :nick:example.org!nick@example.org PRIVMSG #test:example.org :first message\r\n" + ) + + assert_line( + "@msgid=$event2 :admin:example.org!admin@example.org REDACT #test:example.org :$event1\r\n" + ) + + assert_line( + "@msgid=$event3 :admin:example.org!admin@example.org REDACT #test:example.org $event1 :Redacting again!\r\n" + ) + + assert_last_line() + end + + test "message redaction fallback" do M51.IrcConn.State.add_capabilities(:process_ircconn_state, [ :multiline, :message_tags