From c79c1432b19391598a60947fc58022e2ad952ca9 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 7 Aug 2026 17:33:21 -0500 Subject: [PATCH 1/2] Add chat_edit() The foundation for a progress message: post once, then keep replacing it, instead of narrating into the channel one message per tool call. Matrix builds m.new_content plus an m.replace relation on top of mx_send()'s extra hook -- no mx.api change needed. The fallback body keeps the conventional "* " prefix, because a client too old to understand edits renders the event as an ordinary message and the asterisk is what tells a reader it is a correction rather than the bot repeating itself. Markdown renders into both copies; carrying it in only one shows markup on old clients and loses it on new ones. Encrypted rooms refuse, and capabilities report edits = FALSE on an e2ee client to match -- the same bargain attachments get. An edit carries its replacement text in an ordinary event, and crypto_ops$send() takes text, msgtype, markdown and mentions with nowhere to put a relation, so the Megolm path cannot carry one either. The default throws rather than returning quietly. Stale text is worse than a visible failure: a reader cannot tell that what they are looking at is no longer true. --- DESCRIPTION | 2 +- NAMESPACE | 5 +++ R/contract.R | 46 ++++++++++++++++++++ R/loopback.R | 22 +++++++++- R/matrix.R | 60 ++++++++++++++++++++++++-- R/slack.R | 18 +++++++- inst/tinytest/test_contract.R | 37 ++++++++++++++++ inst/tinytest/test_matrix.R | 81 +++++++++++++++++++++++++++++++++++ inst/tinytest/test_slack.R | 35 +++++++++++++++ man/chat_edit.Rd | 56 ++++++++++++++++++++++++ man/chat_matrix.Rd | 6 ++- 11 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 man/chat_edit.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 5cf6f8b..2113b7c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.17 +Version: 0.0.1.18 Date: 2026-08-05 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NAMESPACE b/NAMESPACE index 55aa2ae..5dcce39 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -7,6 +7,7 @@ export(chat_channels) export(chat_config) export(chat_config_save) export(chat_disconnect) +export(chat_edit) export(chat_history) export(chat_identity) export(chat_invite) @@ -49,6 +50,10 @@ S3method(chat_channels,chat_slack) S3method(chat_channels,default) S3method(chat_disconnect,chat_irc) S3method(chat_disconnect,default) +S3method(chat_edit,chat_loopback) +S3method(chat_edit,chat_matrix) +S3method(chat_edit,chat_slack) +S3method(chat_edit,default) S3method(chat_history,chat_loopback) S3method(chat_history,chat_matrix) S3method(chat_history,chat_slack) diff --git a/R/contract.R b/R/contract.R index bd76414..bbd3ca8 100644 --- a/R/contract.R +++ b/R/contract.R @@ -737,3 +737,49 @@ chat_relogin.default <- function(client, ...) { stop("chat_relogin() is not supported by this adapter (", paste(class(client), collapse = "/"), ").", call. = FALSE) } + +#' Replace the text of a message already sent +#' +#' What makes a progress message possible: post "working on it", then +#' keep replacing it as the work happens, instead of narrating into the +#' channel one message at a time. +#' +#' The default throws. An edit that silently does nothing leaves the old +#' text on screen, and stale content is worse than a visible failure -- +#' the reader has no way to tell that what they are looking at is no +#' longer true. Check \code{chat_capabilities()$edits}. +#' +#' @param client A \code{chat_client}. +#' @param channel Channel/room identifier. +#' @param message_id The message to replace, as returned by +#' \code{\link{chat_send}}. +#' @param text The replacement text, in full. Not a delta: every +#' platform that supports this takes the whole new body, and a +#' contract that took a patch would have to reconstruct the old one to +#' apply it. +#' @param markup \code{"plain"} or \code{"markdown"}, as +#' \code{\link{chat_send}}. +#' @param ... Adapter-specific options. +#' @return The identifier of the event the edit created where the +#' platform makes one (Matrix), or of the edited message where it does +#' not (Slack), invisibly. +#' +#' @section What a consumer must not assume: +#' That the edit is what readers see. A client that does not implement +#' edits shows the original and an "* edited" fallback beside it, and +#' notifications almost always carry the text as first sent. So the +#' first version has to stand on its own -- "working on it" is a fine +#' thing to be paged with, a half-finished sentence is not. +#' @export +chat_edit <- function(client, channel, message_id, text, + markup = c("plain", "markdown"), ...) { + UseMethod("chat_edit") +} + +#' @export +chat_edit.default <- function(client, channel, message_id, text, + markup = c("plain", "markdown"), ...) { + stop("chat_edit() is not supported by this adapter (", + paste(class(client), collapse = "/"), + "). Check chat_capabilities()$edits.", call. = FALSE) +} diff --git a/R/loopback.R b/R/loopback.R index 73b3822..85c414d 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -57,7 +57,7 @@ chat_resolve.chat_loopback <- function(client, name, ...) { #' @export chat_capabilities.chat_loopback <- function(client, ...) { - list(threads = TRUE, thread_replies = TRUE, edits = FALSE, + list(threads = TRUE, thread_replies = TRUE, edits = TRUE, reactions = FALSE, reaction_events = FALSE, channel_info = FALSE, members = FALSE, invites = FALSE, join = FALSE, whoami = TRUE, channels = TRUE, history = TRUE, pending = FALSE, @@ -113,3 +113,23 @@ chat_history.chat_loopback <- function(client, channel, limit = 50L, } list(messages = log, cursor = nxt) } + +#' @export +chat_edit.chat_loopback <- function(client, channel, message_id, text, + markup = c("plain", "markdown"), ...) { + markup <- match.arg(markup) + pos <- which(vapply(client$env$log, + function(m) identical(m$id, message_id), logical(1))) + if (!length(pos)) { + # Not a no-op. A consumer editing a message that is not there has + # lost track of what it sent, and the reference adapter is where + # that should be loudest. + stop("chat_edit(): no message ", message_id, " in this log.", + call. = FALSE) + } + msg <- client$env$log[[pos[[1L]]]] + msg$body <- text + msg$markup <- markup + client$env$log[[pos[[1L]]]] <- msg + invisible(message_id) +} diff --git a/R/matrix.R b/R/matrix.R index ce7f5db..81162df 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -134,6 +134,8 @@ #' \code{mx.api::mx_read_receipt}. Leave NULL in production. #' @param .identity Testing seam: replacement for #' \code{mx.client::mx_set_displayname}. Leave NULL in production. +#' @param .edit Testing seam: replacement for \code{mx.api::mx_send} on +#' the edit path. Leave NULL in production. #' @return A \code{chat_client} of class \code{chat_matrix}. #' \code{\link{chat_poll}} on this class returns \code{first_run} and #' \code{client} alongside \code{messages}, \code{cursor}, and @@ -151,7 +153,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .channels = NULL, .history = NULL, .pending = NULL, - .read = NULL, .identity = NULL) { + .read = NULL, .identity = NULL, .edit = NULL) { seams <- list(.sync, .extract, .send, .media) if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) && !requireNamespace("mx.client", quietly = TRUE)) { @@ -207,7 +209,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, info_fn = .info, members_fn = .members, join_fn = .join, channels_fn = .channels, history_fn = .history, pending_fn = .pending, read_fn = .read, - identity_fn = .identity, + identity_fn = .identity, edit_fn = .edit, crypto_ops = matrix_crypto_ops(.crypto)), class = c("chat_matrix", "chat_client")) } @@ -705,7 +707,11 @@ chat_capabilities.chat_matrix <- function(client, ...) { # adapter has no encrypted-attachment path, so chat_send() refuses # attachments to an encrypted room. TRUE would advertise something # that fails in exactly the rooms such a client exists for. - list(threads = FALSE, thread_replies = FALSE, edits = FALSE, + list(threads = FALSE, thread_replies = FALSE, + # Refused in encrypted rooms: an edit carries its replacement + # text in an ordinary event, and there is no Megolm path that + # can carry a relation. Same bargain as files. + edits = !isTRUE(client$e2ee), reactions = TRUE, reaction_events = matrix_reactions_available(), channel_info = TRUE, members = TRUE, invites = matrix_invites_available(), join = TRUE, whoami = TRUE, @@ -891,3 +897,51 @@ chat_mark_read.chat_matrix <- function(client, channel, message_id, ...) { }, error = function(e) FALSE) invisible(ok) } + +#' @export +chat_edit.chat_matrix <- function(client, channel, message_id, text, + markup = c("plain", "markdown"), ...) { + markup <- match.arg(markup) + # Refused in encrypted rooms, and chat_capabilities() reports + # edits = FALSE on an e2ee client to match -- the same bargain + # attachments get. + # + # An edit is an ordinary m.room.message carrying m.new_content, so + # sending one the plain way puts the replacement text on the + # homeserver in the clear, in a room whose whole point is that it is + # not. The Megolm path cannot carry it either: crypto_ops$send() + # takes text, msgtype, markdown and mentions, and there is nowhere in + # that shape to put a relation. + crypto <- matrix_crypto_require(client) + if (!is.null(crypto) && + client$crypto_ops$encrypted(crypto, client$env$mx, channel)) { + stop("chat.api: cannot edit a message in the encrypted room ", + channel, ". An edit carries the replacement text in an ", + "ordinary event, and posting one would put it on the ", + "homeserver in the clear.", call. = FALSE) + } + sess <- mx.client::mx_client_session(client$env$mx) + fn <- client$edit_fn %||% mx.api::mx_send + html <- if (identical(markup, "markdown")) { + mx.client::mx_markdown_to_html(text) + } else { + NULL + } + new_content <- list(msgtype = "m.text", body = text) + if (!is.null(html)) { + new_content$format <- "org.matrix.custom.html" + new_content$formatted_body <- html + } + extra <- list(`m.new_content` = new_content, + `m.relates_to` = list(rel_type = "m.replace", event_id = message_id)) + if (!is.null(html)) { + extra$format <- "org.matrix.custom.html" + # The "* " prefix is the convention for the fallback copy: a + # client too old to understand m.replace renders this event as + # an ordinary message, and the asterisk is what tells a reader + # it is a correction rather than the bot repeating itself. + extra$formatted_body <- paste0("* ", html) + } + invisible(as.character(fn(sess, channel, paste0("* ", text), + msgtype = "m.text", extra = extra))) +} diff --git a/R/slack.R b/R/slack.R index 5142bd7..785b045 100644 --- a/R/slack.R +++ b/R/slack.R @@ -376,7 +376,7 @@ chat_capabilities.chat_slack <- function(client, ...) { # chat_reaction() from that a consumer could deduplicate across polls. # Reading them as events needs the Events API or Socket Mode, which is # a different transport than this one. - list(threads = TRUE, thread_replies = FALSE, edits = FALSE, + list(threads = TRUE, thread_replies = FALSE, edits = TRUE, reactions = TRUE, reaction_events = FALSE, channel_info = TRUE, members = TRUE, # A Slack bot is added to a channel rather than invited, and @@ -534,3 +534,19 @@ chat_set_identity.chat_slack <- function(client, display, ...) { client$env$whoami <- NULL invisible(TRUE) } + +#' @export +chat_edit.chat_slack <- function(client, channel, message_id, text, + markup = c("plain", "markdown"), ...) { + markup <- match.arg(markup) + api <- client$api_fn %||% slackr::call_slack_api + resp <- api("/api/chat.update", .method = "POST", token = client$token, + body = list(channel = sub("^#", "", channel), ts = message_id, + text = slack_render(text, markup))) + slack_stop_for_error(resp, "chat.update") + # Slack edits in place, so the identifier is the one that went in. + # Matrix mints a new event for the edit and returns that instead -- + # the contract promises "the id of the thing that happened", not + # that the two adapters agree on which thing that is. + invisible(message_id) +} diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index a20efe4..db3e4ca 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -276,3 +276,40 @@ local({ # token out of a previous response. expect_true("cursor" %in% names(formals(chat_history))) expect_false("before" %in% names(formals(chat_history))) + +# ---- Edits on the reference adapter ---- +local({ + cl <- chat_loopback() + id <- chat_send(cl, "general", "working on it") + expect_identical(chat_history(cl, "general")$messages[[1L]]$body, + "working on it") + expect_identical(chat_edit(cl, "general", id, "done"), id) + # Replaced in place, not appended. An edit that added a message + # would leave the channel reading as a stutter. + h <- chat_history(cl, "general")$messages + expect_identical(length(h), 1L) + expect_identical(h[[1L]]$body, "done") + expect_identical(h[[1L]]$id, id) + # markup rides along, so a plain first draft can become a formatted + # final one. + chat_edit(cl, "general", id, "**done**", markup = "markdown") + expect_identical(chat_history(cl, "general")$messages[[1L]]$markup, + "markdown") +}) + +# Editing something that was never sent is an error, not a no-op. A +# consumer doing that has lost track of what it sent, and the reference +# adapter is where that should be loudest. +expect_error(chat_edit(chat_loopback(), "general", "nope", "x"), + "no message") +expect_true(chat_capabilities(chat_loopback())$edits) + +# IRC has no edits: a line is on the wire and gone. The default method +# is what answers, and it throws. +local({ + cl <- structure(list(env = new.env(parent = emptyenv()), nick = "bot"), + class = c("chat_irc", "chat_client")) + expect_false(chat_capabilities(cl)$edits) + expect_error(chat_edit(cl, "#lab", "1", "x"), + "not supported by this adapter") +}) diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 8ef0de2..b567b95 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1768,3 +1768,84 @@ local({ expect_true(caps$relogin) expect_identical(caps$pending, chat.api:::matrix_invites_available()) }) + +# ---- Edits ---- +# A progress message: post once, then keep replacing it. The alternative +# is narrating into the channel one message per tool call, which is how +# a room gets unreadable. +local({ + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text", + extra = NULL) { + seen <<- list(room_id = room_id, body = body, msgtype = msgtype, + extra = extra) + "$edit1" + }) + expect_identical(chat_edit(cl, "!a:ex", "$orig", "done"), "$edit1") + expect_identical(seen$room_id, "!a:ex") + # m.replace pointing at the original. Without the relation this is + # just a second message saying the same thing. + expect_identical(seen$extra$`m.relates_to`$rel_type, "m.replace") + expect_identical(seen$extra$`m.relates_to`$event_id, "$orig") + # The replacement lives in m.new_content; the top-level body is the + # fallback a client too old to understand edits renders instead. + expect_identical(seen$extra$`m.new_content`$body, "done") + expect_identical(seen$body, "* done") +}) + +# Markdown renders into both copies. A formatted edit whose new_content +# carried only plain text would show the markup on old clients and lose +# it on new ones, which is exactly backwards. +local({ + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text", + extra = NULL) { + seen <<- extra + "$e" + }) + chat_edit(cl, "!a:ex", "$orig", "**bold**", markup = "markdown") + expect_identical(seen$`m.new_content`$format, "org.matrix.custom.html") + expect_true(grepl("bold", + seen$`m.new_content`$formatted_body, fixed = TRUE)) + expect_identical(seen$format, "org.matrix.custom.html") + expect_true(grepl("^\\* ", seen$formatted_body)) +}) +local({ + # Plain markup sets no format at all, rather than an empty one. + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, ...) { + seen <<- list(...)$extra + "$e" + }) + chat_edit(cl, "!a:ex", "$orig", "plain") + expect_null(seen$format) + expect_null(seen$`m.new_content`$format) +}) + +# An encrypted room refuses. The edit carries its replacement text in an +# ordinary event, so sending one would put it on the homeserver in the +# clear -- in the room whose whole point is that it is not. +local({ + ctx <- new.env(parent = emptyenv()) + ops <- list(init = function(...) ctx, + encrypted = function(crypto, mx, room_id) TRUE, + send = function(...) "$enc", + decrypt = function(...) list()) + cl <- seam_client(mx = fake_mx(user_id = "@e2ee-edit:ex"), + .crypto = ops, e2ee = TRUE, + .edit = function(...) stop("must not be reached")) + expect_error(chat_edit(cl, "!secret:ex", "$orig", "shh"), + "in the clear") + # And the capability says so up front, rather than letting a + # consumer find out by trying. + expect_false(chat_capabilities(cl)$edits) +}) +expect_true(chat_capabilities(seam_client())$edits) + +# An adapter without edits says so instead of leaving stale text on +# screen. A reader cannot tell that what they are looking at is no +# longer true, which is worse than a visible failure. +expect_error(chat_edit(structure(list(), class = c("chat_nothing", + "chat_client")), + "!a", "$1", "x"), + "not supported by this adapter") diff --git a/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index eee2dca..dee62d4 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -533,3 +533,38 @@ local({ expect_error(chat_history(slack_api_client(function(...) { list(ok = FALSE, error = "channel_not_found") }), "lab"), "channel_not_found") + +# ---- Edits ---- +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., .method, token) { + seen <<- c(list(path = path, .method = .method), list(...)) + list(ok = TRUE) + }) + expect_identical(chat_edit(cl, "#lab", "1234.5678", "done"), "1234.5678") + expect_identical(seen$path, "/api/chat.update") + expect_identical(seen$.method, "POST") + # body, not dots. call_slack_api() ignores `...` on its POST path, + # which is how reactions.add once went out carrying nothing. + expect_identical(seen$body$channel, "lab") + expect_identical(seen$body$ts, "1234.5678") + expect_identical(seen$body$text, "done") +}) + +# markdown is rendered to mrkdwn, as on the send path. +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., .method, token) { + seen <<- list(...) + list(ok = TRUE) + }) + chat_edit(cl, "lab", "1.1", "**bold**", markup = "markdown") + expect_identical(seen$body$text, "*bold*") +}) + +# Slack refuses in the body with HTTP 200, so an edit it rejected would +# otherwise report success and leave the old text on screen. +expect_error(chat_edit(slack_api_client(function(...) { + list(ok = FALSE, error = "message_not_found") + }), "lab", "1.1", "x"), "message_not_found") +expect_true(chat_capabilities(slack_api_client(function(...) NULL))$edits) diff --git a/man/chat_edit.Rd b/man/chat_edit.Rd new file mode 100644 index 0000000..edeb3b5 --- /dev/null +++ b/man/chat_edit.Rd @@ -0,0 +1,56 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_edit} +\alias{chat_edit} +\title{Replace the text of a message already sent} +\usage{ +chat_edit( + client, + channel, + message_id, + text, + markup = c("plain", "markdown"), + ... +) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{channel}{Channel/room identifier.} + +\item{message_id}{The message to replace, as returned by +\code{\link{chat_send}}.} + +\item{text}{The replacement text, in full. Not a delta: every +platform that supports this takes the whole new body, and a +contract that took a patch would have to reconstruct the old one to +apply it.} + +\item{markup}{\code{"plain"} or \code{"markdown"}, as +\code{\link{chat_send}}.} + +\item{...}{Adapter-specific options.} +} +\value{ +The identifier of the event the edit created where the + platform makes one (Matrix), or of the edited message where it does + not (Slack), invisibly. +} +\description{ +What makes a progress message possible: post "working on it", then +keep replacing it as the work happens, instead of narrating into the +channel one message at a time. +} +\details{ +The default throws. An edit that silently does nothing leaves the old +text on screen, and stale content is worse than a visible failure -- +the reader has no way to tell that what they are looking at is no +longer true. Check \code{chat_capabilities()$edits}. + +} +\section{What a consumer must not assume}{ +That the edit is what readers see. A client that does not implement +edits shows the original and an "* edited" fallback beside it, and +notifications almost always carry the text as first sent. So the +first version has to stand on its own -- "working on it" is a fine +thing to be paged with, a half-finished sentence is not. +} diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 37c4d66..7a92c45 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -26,7 +26,8 @@ chat_matrix( .history = NULL, .pending = NULL, .read = NULL, - .identity = NULL + .identity = NULL, + .edit = NULL ) } \arguments{ @@ -177,6 +178,9 @@ Leave NULL in production.} \item{.identity}{Testing seam: replacement for \code{mx.client::mx_set_displayname}. Leave NULL in production.} + +\item{.edit}{Testing seam: replacement for \code{mx.api::mx_send} on +the edit path. Leave NULL in production.} } \value{ A \code{chat_client} of class \code{chat_matrix}. From 5d09a15f9d1b5368da50bc82b9b1cdee20e43d39 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Fri, 7 Aug 2026 17:56:34 -0500 Subject: [PATCH 2/2] chat_send() and chat_edit() take adapter-native markup A room can now be shown a collapsible
block, which is the whole reason chat_edit() exists -- a progress trail nobody can collapse is a wall of text that grows. There was no way to express one. markup = "markdown" renders through mx_markdown_to_html(), which escapes raw HTML: correct for a conservative subset, and a dead end for a caller that has markup the subset cannot reach. So `rich` carries a fragment the adapter passes through as formatted_body, and `text` stays required as the body. That is Matrix's own model, and it is load-bearing rather than tidy: text is what a client too old to render the markup shows, what a push notification carries, and what every other transport gets. Ignored rather than refused where unsupported, on chat_typing()'s reasoning -- the text is the message, the markup is decoration. chat_capabilities()$rich_markup names what an adapter accepts, and is empty on an e2ee client: the Megolm path builds its own HTML from markdown and has nowhere to put a supplied fragment. Placed last in the signature, after notify, so nothing calling positionally past `text` shifts. --- DESCRIPTION | 2 +- R/contract.R | 23 ++++++++++-- R/irc.R | 7 ++-- R/loopback.R | 9 +++-- R/matrix.R | 52 ++++++++++++++++++++++++-- R/slack.R | 9 +++-- inst/tinytest/test_matrix.R | 74 +++++++++++++++++++++++++++++++++++++ man/chat_edit.Rd | 1 + man/chat_matrix.Rd | 6 ++- man/chat_send.Rd | 17 +++++++++ 10 files changed, 182 insertions(+), 18 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2113b7c..6178cbf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.18 +Version: 0.0.1.19 Date: 2026-08-05 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/R/contract.R b/R/contract.R index bbd3ca8..204bdc6 100644 --- a/R/contract.R +++ b/R/contract.R @@ -37,6 +37,21 @@ chat_poll <- function(client, since = NULL, timeout = NULL, ...) { #' @param files Character vector of file paths to attach, or NULL. #' @param kind Message kind; \code{"message"} (default) or an #' adapter-understood alternative (e.g. \code{"notice"}, \code{"emote"}). +#' @param rich Adapter-native markup for the platforms that accept it, +#' or NULL. Matrix takes an HTML fragment and sends it as +#' \code{formatted_body}; adapters whose +#' \code{chat_capabilities()} is empty ignore it. +#' +#' \code{text} is still required and still has to stand on its own. It +#' is what a client that cannot render the markup shows, what a push +#' notification carries, and what every other transport gets -- so a +#' \code{rich} that holds the real content and a \code{text} that says +#' "see above" is a message half the room cannot read. +#' +#' Ignored rather than refused where unsupported, on +#' \code{\link{chat_typing}}'s reasoning: the text is the message and +#' the markup is decoration, so losing it costs presentation and +#' nothing else. #' @param notify Logical; FALSE requests a silent delivery where #' supported. #' @param ... Adapter-specific options. @@ -50,7 +65,8 @@ chat_poll <- function(client, since = NULL, timeout = NULL, ...) { #' @export chat_send <- function(client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, - files = NULL, kind = "message", notify = TRUE, ...) { + files = NULL, kind = "message", notify = TRUE, + rich = NULL, ...) { UseMethod("chat_send") } @@ -772,13 +788,14 @@ chat_relogin.default <- function(client, ...) { #' thing to be paged with, a half-finished sentence is not. #' @export chat_edit <- function(client, channel, message_id, text, - markup = c("plain", "markdown"), ...) { + markup = c("plain", "markdown"), rich = NULL, ...) { UseMethod("chat_edit") } #' @export chat_edit.default <- function(client, channel, message_id, text, - markup = c("plain", "markdown"), ...) { + markup = c("plain", "markdown"), rich = NULL, + ...) { stop("chat_edit() is not supported by this adapter (", paste(class(client), collapse = "/"), "). Check chat_capabilities()$edits.", call. = FALSE) diff --git a/R/irc.R b/R/irc.R index cc9bc9b..84a149d 100644 --- a/R/irc.R +++ b/R/irc.R @@ -89,7 +89,8 @@ chat_send.chat_irc <- function(client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, - kind = "message", notify = TRUE, ...) { + kind = "message", notify = TRUE, rich = NULL, + ...) { markup <- match.arg(markup) verb <- if (identical(kind, "notice")) { "NOTICE" @@ -129,8 +130,8 @@ chat_capabilities.chat_irc <- function(client, ...) { channels = FALSE, history = FALSE, pending = FALSE, mark_read = FALSE, set_identity = TRUE, relogin = FALSE, files = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = FALSE, markup_dialects = "plain", - max_message_bytes = 400L) + identity_override = FALSE, rich_markup = character(), + markup_dialects = "plain", max_message_bytes = 400L) } #' @export diff --git a/R/loopback.R b/R/loopback.R index 85c414d..0b1cd93 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -25,7 +25,8 @@ chat_send.chat_loopback <- function(client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, - kind = "message", notify = TRUE, ...) { + kind = "message", notify = TRUE, + rich = NULL, ...) { markup <- match.arg(markup) id <- sprintf("loopback-%d", length(client$env$log) + 1L) msg <- chat_message(id = id, channel = channel, @@ -63,7 +64,8 @@ chat_capabilities.chat_loopback <- function(client, ...) { channels = TRUE, history = TRUE, pending = FALSE, mark_read = FALSE, set_identity = FALSE, relogin = FALSE, files = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, markup_dialects = c("plain", "markdown"), + identity_override = TRUE, rich_markup = character(), + markup_dialects = c("plain", "markdown"), max_message_bytes = NA_integer_) } @@ -116,7 +118,8 @@ chat_history.chat_loopback <- function(client, channel, limit = 50L, #' @export chat_edit.chat_loopback <- function(client, channel, message_id, text, - markup = c("plain", "markdown"), ...) { + markup = c("plain", "markdown"), + rich = NULL, ...) { markup <- match.arg(markup) pos <- which(vapply(client$env$log, function(m) identical(m$id, message_id), logical(1))) diff --git a/R/matrix.R b/R/matrix.R index 81162df..8b90195 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -134,6 +134,8 @@ #' \code{mx.api::mx_read_receipt}. Leave NULL in production. #' @param .identity Testing seam: replacement for #' \code{mx.client::mx_set_displayname}. Leave NULL in production. +#' @param .rich Testing seam: replacement for \code{mx.api::mx_send} on +#' the rich-send path. Leave NULL in production. #' @param .edit Testing seam: replacement for \code{mx.api::mx_send} on #' the edit path. Leave NULL in production. #' @return A \code{chat_client} of class \code{chat_matrix}. @@ -153,7 +155,8 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .channels = NULL, .history = NULL, .pending = NULL, - .read = NULL, .identity = NULL, .edit = NULL) { + .read = NULL, .identity = NULL, .edit = NULL, + .rich = NULL) { seams <- list(.sync, .extract, .send, .media) if ((is.null(mx) || any(vapply(seams, is.null, logical(1)))) && !requireNamespace("mx.client", quietly = TRUE)) { @@ -210,6 +213,7 @@ chat_matrix <- function(app = NULL, path = NULL, save_cursor = TRUE, channels_fn = .channels, history_fn = .history, pending_fn = .pending, read_fn = .read, identity_fn = .identity, edit_fn = .edit, + rich_fn = .rich, crypto_ops = matrix_crypto_ops(.crypto)), class = c("chat_matrix", "chat_client")) } @@ -517,7 +521,8 @@ chat_send.chat_matrix <- function(client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, - kind = "message", notify = TRUE, ...) { + kind = "message", notify = TRUE, + rich = NULL, ...) { markup <- match.arg(markup) # Resolved before anything is uploaded or posted. The encryption # question used to be asked after the attachment loop had already run, @@ -576,12 +581,25 @@ chat_send.chat_matrix <- function(client, channel, text, # markdown send renders the same either way. Only the envelope # differs. if (encrypted) { + # rich is dropped here rather than refused. crypto_ops$send() + # takes text, msgtype, markdown and mentions, and builds its + # own HTML from the markdown -- there is nowhere in that + # shape for a caller's fragment. The text still arrives, and + # chat_capabilities()$rich_markup is empty on an e2ee client + # so a consumer can know beforehand. event <- client$crypto_ops$send(crypto, client$env$mx, channel, text, msgtype = msgtype, markdown = identical(markup, "markdown"), mentions = list(...)$mentions) return(invisible(c(media_ids, as.character(event)))) } + if (!is.null(rich)) { + # A different function, because mx_send_text() renders its + # own HTML from markdown and has no argument for a supplied + # one. mx_send() takes the content wholesale. + event <- matrix_send_rich(client, channel, text, rich, msgtype) + return(invisible(c(media_ids, as.character(event)))) + } event <- client$send_fn(client$env$mx, text, room = channel, msgtype = msgtype, markdown = identical(markup, "markdown"), ...) @@ -719,7 +737,11 @@ chat_capabilities.chat_matrix <- function(client, ...) { pending = matrix_invites_available(), mark_read = TRUE, set_identity = TRUE, relogin = TRUE, files = !isTRUE(client$e2ee), typing = TRUE, e2ee = isTRUE(client$e2ee), - identity_override = FALSE, markup_dialects = c("plain", "markdown"), + identity_override = FALSE, + # Empty on an e2ee client: the Megolm path builds its own HTML + # from markdown and has nowhere to put a supplied fragment. + rich_markup = if (isTRUE(client$e2ee)) character() else "html", + markup_dialects = c("plain", "markdown"), max_message_bytes = NA_integer_) } @@ -900,7 +922,8 @@ chat_mark_read.chat_matrix <- function(client, channel, message_id, ...) { #' @export chat_edit.chat_matrix <- function(client, channel, message_id, text, - markup = c("plain", "markdown"), ...) { + markup = c("plain", "markdown"), + rich = NULL, ...) { markup <- match.arg(markup) # Refused in encrypted rooms, and chat_capabilities() reports # edits = FALSE on an e2ee client to match -- the same bargain @@ -928,6 +951,12 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, NULL } new_content <- list(msgtype = "m.text", body = text) + # A supplied fragment wins over one rendered from markdown: the + # caller has markup the renderer cannot express, which is the only + # reason to pass one. + if (!is.null(rich)) { + html <- rich + } if (!is.null(html)) { new_content$format <- "org.matrix.custom.html" new_content$formatted_body <- html @@ -945,3 +974,18 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, invisible(as.character(fn(sess, channel, paste0("* ", text), msgtype = "m.text", extra = extra))) } + +# A send carrying a caller-supplied HTML fragment. mx_send_text() builds +# formatted_body itself out of markdown and takes no argument for one +# already rendered, so this goes through mx_send(), which takes the +# content wholesale. +# +# text is still the body. Matrix's own model is a plain body plus an +# optional formatted one, and a client that cannot render the markup -- +# or a push notification, which never does -- shows the body. +matrix_send_rich <- function(client, channel, text, rich, msgtype) { + sess <- mx.client::mx_client_session(client$env$mx) + fn <- client$rich_fn %||% mx.api::mx_send + fn(sess, channel, text, msgtype = msgtype, + extra = list(format = "org.matrix.custom.html", formatted_body = rich)) +} diff --git a/R/slack.R b/R/slack.R index 785b045..ce1e44e 100644 --- a/R/slack.R +++ b/R/slack.R @@ -175,7 +175,8 @@ chat_send.chat_slack <- function(client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, - kind = "message", notify = TRUE, ...) { + kind = "message", notify = TRUE, + rich = NULL, ...) { markup <- match.arg(markup) name_override <- if (is.null(identity$name)) { client$username @@ -387,7 +388,8 @@ chat_capabilities.chat_slack <- function(client, ...) { channels = TRUE, history = TRUE, pending = FALSE, mark_read = TRUE, set_identity = TRUE, relogin = FALSE, files = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, markup_dialects = c("plain", "markdown"), + identity_override = TRUE, rich_markup = character(), + markup_dialects = c("plain", "markdown"), max_message_bytes = 40000L) } @@ -537,7 +539,8 @@ chat_set_identity.chat_slack <- function(client, display, ...) { #' @export chat_edit.chat_slack <- function(client, channel, message_id, text, - markup = c("plain", "markdown"), ...) { + markup = c("plain", "markdown"), + rich = NULL, ...) { markup <- match.arg(markup) api <- client$api_fn %||% slackr::call_slack_api resp <- api("/api/chat.update", .method = "POST", token = client$token, diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index b567b95..7c8f23f 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1849,3 +1849,77 @@ expect_error(chat_edit(structure(list(), class = c("chat_nothing", "chat_client")), "!a", "$1", "x"), "not supported by this adapter") + +# ---- Rich markup ---- +# A caller-supplied HTML fragment, for markup the markdown renderer +# cannot express. mx_markdown_to_html() escapes raw HTML -- correctly, +# it is a conservative subset -- so
can only get into a room +# this way. +local({ + seen <- NULL + cl <- seam_client(.rich = function(session, room_id, body, msgtype = "m.text", + extra = NULL) { + seen <<- list(room_id = room_id, body = body, extra = extra) + "$rich1" + }, send = function(...) stop("must not take the plain path")) + id <- chat_send(cl, "!a:ex", "Ran 3 commands", + rich = "
Ran 3 commands
") + expect_identical(id, "$rich1") + expect_identical(seen$extra$format, "org.matrix.custom.html") + expect_identical(seen$extra$formatted_body, + "
Ran 3 commands
") + # text is still the body. It is what a client that cannot render the + # markup shows, and what the push notification carries. + expect_identical(seen$body, "Ran 3 commands") +}) + +# No rich means the ordinary path, untouched. +local({ + took <- NULL + cl <- seam_client(send = function(client, text, room = NULL, ...) { + took <<- "plain" + "$p" + }, .rich = function(...) stop("must not be reached")) + expect_identical(chat_send(cl, "!a:ex", "hi"), "$p") + expect_identical(took, "plain") +}) + +# An edit carries it too, and a supplied fragment beats one rendered +# from markdown -- the caller has markup the renderer cannot express, +# which is the only reason to pass one. +local({ + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text", + extra = NULL) { + seen <<- extra + "$e" + }) + chat_edit(cl, "!a:ex", "$o", "Ran 4 commands", markup = "markdown", + rich = "
Ran 4 commands
") + expect_identical(seen$`m.new_content`$formatted_body, + "
Ran 4 commands
") + expect_identical(seen$`m.new_content`$body, "Ran 4 commands") +}) + +# An e2ee client drops it rather than refusing the send: the text is the +# message and the markup is decoration, so losing it costs presentation +# and nothing else. The capability says so beforehand. +local({ + ctx <- new.env(parent = emptyenv()) + sent <- NULL + ops <- list(init = function(...) ctx, + encrypted = function(crypto, mx, room_id) TRUE, + send = function(crypto, mx, room_id, text, ...) { + sent <<- text + "$enc" + }, + decrypt = function(...) list()) + cl <- seam_client(mx = fake_mx(user_id = "@e2ee-rich:ex"), + .crypto = ops, e2ee = TRUE, + .rich = function(...) stop("must not be reached")) + expect_identical(chat_send(cl, "!secret:ex", "Ran 3 commands", + rich = "
"), "$enc") + expect_identical(sent, "Ran 3 commands") + expect_identical(chat_capabilities(cl)$rich_markup, character()) +}) +expect_identical(chat_capabilities(seam_client())$rich_markup, "html") diff --git a/man/chat_edit.Rd b/man/chat_edit.Rd index edeb3b5..f77377e 100644 --- a/man/chat_edit.Rd +++ b/man/chat_edit.Rd @@ -9,6 +9,7 @@ chat_edit( message_id, text, markup = c("plain", "markdown"), + rich = NULL, ... ) } diff --git a/man/chat_matrix.Rd b/man/chat_matrix.Rd index 7a92c45..435eb48 100644 --- a/man/chat_matrix.Rd +++ b/man/chat_matrix.Rd @@ -27,7 +27,8 @@ chat_matrix( .pending = NULL, .read = NULL, .identity = NULL, - .edit = NULL + .edit = NULL, + .rich = NULL ) } \arguments{ @@ -181,6 +182,9 @@ Leave NULL in production.} \item{.edit}{Testing seam: replacement for \code{mx.api::mx_send} on the edit path. Leave NULL in production.} + +\item{.rich}{Testing seam: replacement for \code{mx.api::mx_send} on +the rich-send path. Leave NULL in production.} } \value{ A \code{chat_client} of class \code{chat_matrix}. diff --git a/man/chat_send.Rd b/man/chat_send.Rd index 99c5a8e..fd55434 100644 --- a/man/chat_send.Rd +++ b/man/chat_send.Rd @@ -14,6 +14,7 @@ chat_send( files = NULL, kind = "message", notify = TRUE, + rich = NULL, ... ) } @@ -44,6 +45,22 @@ adapter-understood alternative (e.g. \code{"notice"}, \code{"emote"}).} \item{notify}{Logical; FALSE requests a silent delivery where supported.} +\item{rich}{Adapter-native markup for the platforms that accept it, +or NULL. Matrix takes an HTML fragment and sends it as +\code{formatted_body}; adapters whose +\code{chat_capabilities()} is empty ignore it. + +\code{text} is still required and still has to stand on its own. It +is what a client that cannot render the markup shows, what a push +notification carries, and what every other transport gets -- so a +\code{rich} that holds the real content and a \code{text} that says +"see above" is a message half the room cannot read. + +Ignored rather than refused where unsupported, on +\code{\link{chat_typing}}'s reasoning: the text is the message and +the markup is decoration, so losing it costs presentation and +nothing else.} + \item{...}{Adapter-specific options.} } \value{