diff --git a/DESCRIPTION b/DESCRIPTION
index 5cf6f8b..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.17
+Version: 0.0.1.19
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..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")
}
@@ -737,3 +753,50 @@ 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"), rich = NULL, ...) {
+ UseMethod("chat_edit")
+}
+
+#' @export
+chat_edit.default <- function(client, channel, message_id, text,
+ 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 73b3822..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,
@@ -57,13 +58,14 @@ 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,
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_)
}
@@ -113,3 +115,24 @@ 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"),
+ rich = NULL, ...) {
+ 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..8b90195 100644
--- a/R/matrix.R
+++ b/R/matrix.R
@@ -134,6 +134,10 @@
#' \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}.
#' \code{\link{chat_poll}} on this class returns \code{first_run} and
#' \code{client} alongside \code{messages}, \code{cursor}, and
@@ -151,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) {
+ .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)) {
@@ -207,7 +212,8 @@ 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,
+ rich_fn = .rich,
crypto_ops = matrix_crypto_ops(.crypto)),
class = c("chat_matrix", "chat_client"))
}
@@ -515,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,
@@ -574,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"), ...)
@@ -705,7 +725,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,
@@ -713,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_)
}
@@ -891,3 +919,73 @@ 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"),
+ 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
+ # 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)
+ # 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
+ }
+ 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)))
+}
+
+# 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 5142bd7..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
@@ -376,7 +377,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
@@ -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)
}
@@ -534,3 +536,20 @@ 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"),
+ rich = NULL, ...) {
+ 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..7c8f23f 100644
--- a/inst/tinytest/test_matrix.R
+++ b/inst/tinytest/test_matrix.R
@@ -1768,3 +1768,158 @@ 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")
+
+# ---- 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/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..f77377e
--- /dev/null
+++ b/man/chat_edit.Rd
@@ -0,0 +1,57 @@
+% 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"),
+ rich = NULL,
+ ...
+)
+}
+\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..435eb48 100644
--- a/man/chat_matrix.Rd
+++ b/man/chat_matrix.Rd
@@ -26,7 +26,9 @@ chat_matrix(
.history = NULL,
.pending = NULL,
.read = NULL,
- .identity = NULL
+ .identity = NULL,
+ .edit = NULL,
+ .rich = NULL
)
}
\arguments{
@@ -177,6 +179,12 @@ 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.}
+
+\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{