diff --git a/DESCRIPTION b/DESCRIPTION index 6178cbf..30c871b 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.19 +Version: 0.0.1.20 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 204bdc6..731a687 100644 --- a/R/contract.R +++ b/R/contract.R @@ -775,6 +775,10 @@ chat_relogin.default <- function(client, ...) { #' apply it. #' @param markup \code{"plain"} or \code{"markdown"}, as #' \code{\link{chat_send}}. +#' @param kind Message kind, as \code{\link{chat_send}}. Must match what +#' the message was sent as: Matrix carries the msgtype inside the +#' replacement content, so editing an \code{m.notice} without saying so +#' turns it into an ordinary message. #' @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 @@ -788,14 +792,15 @@ 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"), rich = NULL, ...) { + markup = c("plain", "markdown"), rich = NULL, + kind = "message", ...) { UseMethod("chat_edit") } #' @export chat_edit.default <- function(client, channel, message_id, text, markup = c("plain", "markdown"), rich = NULL, - ...) { + kind = "message", ...) { 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 0b1cd93..a7863a1 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -119,7 +119,7 @@ chat_history.chat_loopback <- function(client, channel, limit = 50L, #' @export chat_edit.chat_loopback <- function(client, channel, message_id, text, markup = c("plain", "markdown"), - rich = NULL, ...) { + rich = NULL, kind = "message", ...) { 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 8b90195..e9d36e3 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -550,23 +550,7 @@ chat_send.chat_matrix <- function(client, channel, text, media_ids <- c(media_ids, as.character(event)) } } - # The contract's three kinds, plus a documented way past them: a - # kind already spelled as a Matrix msgtype goes out as itself. The - # contract has no word for an image or a file, and mapping those to - # "m.text" -- which is what the else branch below does to anything - # unrecognized -- posts a text message whose body is a filename. - # Better a caller that names a Matrix type explicitly than a caller - # forced around the contract entirely to send one. - msgtype <- if (identical(kind, "notice")) { - "m.notice" - } else if (identical(kind, "emote")) { - "m.emote" - } else if (is.character(kind) && length(kind) == 1L && - startsWith(kind, "m.")) { - kind - } else { - "m.text" - } + msgtype <- matrix_msgtype(kind) # An attachment-only send is the uploads and nothing else. Matrix # accepts an empty body and clients render it as a blank bubble, so # posting one after every file leaves visible litter in the room. @@ -923,7 +907,7 @@ 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"), - rich = NULL, ...) { + rich = NULL, kind = "message", ...) { markup <- match.arg(markup) # Refused in encrypted rooms, and chat_capabilities() reports # edits = FALSE on an e2ee client to match -- the same bargain @@ -950,7 +934,8 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, } else { NULL } - new_content <- list(msgtype = "m.text", body = text) + msgtype <- matrix_msgtype(kind) + new_content <- list(msgtype = msgtype, 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. @@ -972,7 +957,7 @@ chat_edit.chat_matrix <- function(client, channel, message_id, text, extra$formatted_body <- paste0("* ", html) } invisible(as.character(fn(sess, channel, paste0("* ", text), - msgtype = "m.text", extra = extra))) + msgtype = msgtype, extra = extra))) } # A send carrying a caller-supplied HTML fragment. mx_send_text() builds @@ -989,3 +974,27 @@ matrix_send_rich <- function(client, channel, text, rich, msgtype) { fn(sess, channel, text, msgtype = msgtype, extra = list(format = "org.matrix.custom.html", formatted_body = rich)) } + +# The contract's kind vocabulary as a Matrix msgtype, plus a documented +# way past it: a kind already spelled as a msgtype goes out as itself. +# The contract has no word for an image or a file, and mapping those to +# m.text -- which is what an unrecognized kind falls to -- posts a text +# message whose body is a filename. Better a caller that names a Matrix +# type explicitly than one forced around the contract to send it. +# +# Shared by chat_send() and chat_edit(): an edit carries the msgtype in +# its m.new_content, so an edit that assumed m.text would quietly turn +# an m.notice into an ordinary message the first time it fired -- and +# m.notice is what stops a bot's own output triggering other bots. +matrix_msgtype <- function(kind) { + if (identical(kind, "notice")) { + "m.notice" + } else if (identical(kind, "emote")) { + "m.emote" + } else if (is.character(kind) && length(kind) == 1L && + startsWith(kind, "m.")) { + kind + } else { + "m.text" + } +} diff --git a/R/slack.R b/R/slack.R index ce1e44e..ff74c3f 100644 --- a/R/slack.R +++ b/R/slack.R @@ -540,7 +540,7 @@ chat_set_identity.chat_slack <- function(client, display, ...) { #' @export chat_edit.chat_slack <- function(client, channel, message_id, text, markup = c("plain", "markdown"), - rich = NULL, ...) { + rich = NULL, kind = "message", ...) { 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 7c8f23f..0b06d40 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1923,3 +1923,36 @@ local({ expect_identical(chat_capabilities(cl)$rich_markup, character()) }) expect_identical(chat_capabilities(seam_client())$rich_markup, "html") + +# ---- An edit keeps the message's kind ---- +# Matrix carries the msgtype inside m.new_content, so an edit that +# assumed m.text would turn an m.notice into an ordinary message the +# first time it fired -- and m.notice is what keeps a bot's own output +# from triggering other bots. +local({ + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text", + extra = NULL) { + seen <<- list(msgtype = msgtype, extra = extra) + "$e" + }) + chat_edit(cl, "!a:ex", "$o", "Ran 3 commands", kind = "notice") + expect_identical(seen$msgtype, "m.notice") + expect_identical(seen$extra$`m.new_content`$msgtype, "m.notice") +}) +local({ + # Default is unchanged. + seen <- NULL + cl <- seam_client(.edit = function(session, room_id, body, msgtype = "m.text", + ...) { + seen <<- msgtype + "$e" + }) + chat_edit(cl, "!a:ex", "$o", "x") + expect_identical(seen, "m.text") +}) +expect_identical(chat.api:::matrix_msgtype("notice"), "m.notice") +expect_identical(chat.api:::matrix_msgtype("emote"), "m.emote") +expect_identical(chat.api:::matrix_msgtype("message"), "m.text") +expect_identical(chat.api:::matrix_msgtype("m.image"), "m.image") +expect_identical(chat.api:::matrix_msgtype(NULL), "m.text") diff --git a/man/chat_edit.Rd b/man/chat_edit.Rd index f77377e..47344dc 100644 --- a/man/chat_edit.Rd +++ b/man/chat_edit.Rd @@ -10,6 +10,7 @@ chat_edit( text, markup = c("plain", "markdown"), rich = NULL, + kind = "message", ... ) } @@ -29,6 +30,11 @@ apply it.} \item{markup}{\code{"plain"} or \code{"markdown"}, as \code{\link{chat_send}}.} +\item{kind}{Message kind, as \code{\link{chat_send}}. Must match what +the message was sent as: Matrix carries the msgtype inside the +replacement content, so editing an \code{m.notice} without saying so +turns it into an ordinary message.} + \item{...}{Adapter-specific options.} } \value{