From 0b8882c33e3906eb0d1be54405eb155c74c613c7 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Sun, 2 Aug 2026 09:09:05 -0500 Subject: [PATCH 1/4] Add Matrix HTML table helpers and pipe-table Markdown support mx_table_html() and mx_send_table() render a data frame, matrix, or list as the conservative table HTML Matrix clients accept: a bare of /
/ with no CSS, colspan, rowspan, or custom attributes. mx_send_table() pairs it with a plain-text body for clients that ignore formatted_body. mx_markdown_to_html() gains GitHub-style pipe tables, honouring the :---/:---:/---: alignment row. Adds inst/skills/mx.client/matrix-messaging/SKILL.md. --- NAMESPACE | 2 + R/markdown.R | 170 ++++++++++++++---- R/tables.R | 94 ++++++++++ .../mx.client/matrix-messaging/SKILL.md | 148 +++++++++++++++ inst/tinytest/test_markdown.R | 33 ++++ inst/tinytest/test_tables.R | 13 ++ man/mx_markdown_to_html.Rd | 2 +- man/mx_pill_mentions.Rd | 18 +- man/mx_send_table.Rd | 34 ++++ man/mx_table_html.Rd | 24 +++ 10 files changed, 495 insertions(+), 43 deletions(-) create mode 100644 R/tables.R create mode 100644 inst/skills/mx.client/matrix-messaging/SKILL.md create mode 100644 inst/tinytest/test_tables.R create mode 100644 man/mx_send_table.Rd create mode 100644 man/mx_table_html.Rd diff --git a/NAMESPACE b/NAMESPACE index 0153a1d..66bb577 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -36,6 +36,8 @@ export(mx_room_encrypted) export(mx_room_lookup_by_name) export(mx_send_encrypted) export(mx_send_media) +export(mx_send_table) export(mx_send_text) export(mx_sync_update) +export(mx_table_html) export(mx_with_relogin) diff --git a/R/markdown.R b/R/markdown.R index f0262c7..454f3ac 100644 --- a/R/markdown.R +++ b/R/markdown.R @@ -15,10 +15,89 @@ mx_markdown_inline_html <- function(x) { x } +mx_table_row_cells <- function(x) { + x <- trimws(x) + if (startsWith(x, "|")) { + x <- substring(x, 2L) + } + if (endsWith(x, "|")) { + x <- substring(x, 1L, nchar(x) - 1L) + } + trimws(strsplit(x, "|", fixed = TRUE)[[1]]) +} + +mx_is_table_separator <- function(x) { + cells <- mx_table_row_cells(x) + length(cells) > 0L && all(grepl("^:?-{3,}:?$", cells)) +} + +mx_is_table_row <- function(x) { + grepl("\\|", x) && nzchar(trimws(x)) +} + +mx_table_align <- function(sep) { + cells <- mx_table_row_cells(sep) + vapply(cells, function(cell) { + left <- startsWith(cell, ":") + right <- endsWith(cell, ":") + if (left && right) { + "center" + } else if (right) { + "right" + } else if (left) { + "left" + } else { + NA_character_ + } + }, character(1)) +} + +mx_table_html_cells <- function(cells, tag = "td", align = NULL) { + n <- length(cells) + if (is.null(align)) { + align <- rep(NA_character_, n) + } + if (length(align) < n) { + align <- c(align, rep(NA_character_, n - length(align))) + } + paste(vapply(seq_len(n), function(i) { + attr <- if (!is.na(align[[i]]) && nzchar(align[[i]])) { + sprintf(" align=\"%s\"", align[[i]]) + } else { + "" + } + sprintf("<%s%s>%s", tag, attr, + mx_markdown_inline_html(cells[[i]]), tag) + }, character(1)), collapse = "") +} + +mx_table_to_html <- function(rows) { + header <- mx_table_row_cells(rows[[1]]) + align <- mx_table_align(rows[[2]]) + body <- rows[-c(1L, 2L)] + html <- c("", "", + mx_table_html_cells(header, "th", align), + "") + if (length(body)) { + body_html <- vapply(body, function(row) { + cells <- mx_table_row_cells(row) + # Pad or trim body rows to header width, matching common GFM behavior. + if (length(cells) < length(header)) { + cells <- c(cells, rep("", length(header) - length(cells))) + } else if (length(cells) > length(header)) { + cells <- cells[seq_along(header)] + } + paste0("", mx_table_html_cells(cells, "td", align), "") + }, character(1)) + html <- c(html, "", body_html, "") + } + paste0(c(html, "
"), collapse = "") +} + #' Convert a conservative markdown subset to Matrix custom HTML #' #' Supports headings, bullets, numbered lists, fenced code blocks, inline -#' code, bold, and simple underscore emphasis. +#' code, bold, simple underscore emphasis, and GitHub-style pipe tables. #' #' @param text Character markdown body. #' @return Character HTML suitable for m.room.message formatted_body. @@ -43,7 +122,9 @@ mx_markdown_to_html <- function(text) { } z } - for (ln in lines) { + i <- 1L + while (i <= length(lines)) { + ln <- lines[[i]] if (grepl("^```", ln)) { if (in_pre) { out <- c(out, "") @@ -52,76 +133,103 @@ mx_markdown_to_html <- function(text) { out <- c(out, close_lists(), "
")
                 in_pre <- TRUE
             }
+            i <- i + 1L
             next
         }
         if (in_pre) {
             out <- c(out, mx_html_escape(ln))
+            i <- i + 1L
+            next
+        }
+        if (i < length(lines) && mx_is_table_row(ln) &&
+            mx_is_table_separator(lines[[i + 1L]])) {
+            j <- i + 2L
+            while (j <= length(lines) && mx_is_table_row(lines[[j]])) {
+                j <- j + 1L
+            }
+            out <- c(out, close_lists(), mx_table_to_html(lines[i:(j - 1L)]))
+            i <- j
             next
         }
         if (!nzchar(trimws(ln))) {
             out <- c(out, close_lists())
+            i <- i + 1L
             next
         }
-        if (grepl("^#{1,6}\\s+", ln)) {
+        h <- regexec("^(#{1,6})\\s+(.+)$", ln, perl = TRUE)
+        hm <- regmatches(ln, h)[[1]]
+        if (length(hm)) {
             out <- c(out, close_lists())
-            lvl <- nchar(sub("^(#{1,6}).*$", "\\1", ln))
-            body <- sub("^#{1,6}\\s+", "", ln)
+            lvl <- nchar(hm[[2]])
+            body <- hm[[3]]
             out <- c(out, sprintf("%s", lvl,
                                   mx_markdown_inline_html(body), lvl))
+            i <- i + 1L
             next
         }
-        if (grepl("^\\s*[-*]\\s+", ln)) {
+        b <- regexec("^\\s*[-*]\\s+(.+)$", ln, perl = TRUE)
+        bm <- regmatches(ln, b)[[1]]
+        if (length(bm)) {
+            if (in_ol) {
+                out <- c(out, "")
+                in_ol <- FALSE
+            }
             if (!in_ul) {
-                out <- c(out, close_lists(), "
    ") + out <- c(out, "
      ") in_ul <- TRUE } - body <- sub("^\\s*[-*]\\s+", "", ln) + body <- bm[[2]] out <- c(out, sprintf("
    • %s
    • ", mx_markdown_inline_html(body))) + i <- i + 1L next } - if (grepl("^\\s*[0-9]+[.)]\\s+", ln)) { + o <- regexec("^\\s*[0-9]+\\.\\s+(.+)$", ln, perl = TRUE) + om <- regmatches(ln, o)[[1]] + if (length(om)) { + if (in_ul) { + out <- c(out, "
    ") + in_ul <- FALSE + } if (!in_ol) { - out <- c(out, close_lists(), "
      ") + out <- c(out, "
        ") in_ol <- TRUE } - body <- sub("^\\s*[0-9]+[.)]\\s+", "", ln) + body <- om[[2]] out <- c(out, sprintf("
      1. %s
      2. ", mx_markdown_inline_html(body))) + i <- i + 1L next } out <- c(out, close_lists(), sprintf("

        %s

        ", - mx_markdown_inline_html(ln))) + mx_markdown_inline_html(ln))) + i <- i + 1L } - out <- c(out, close_lists()) if (in_pre) { out <- c(out, "
") } + out <- c(out, close_lists()) paste(out, collapse = "") } -#' Turn textual @mentions into matrix.to pills -#' -#' Replaces each occurrence of \code{@localpart} (or the full -#' \code{@localpart:server} id) in already-rendered HTML with a -#' \code{matrix.to} anchor, which Matrix clients render as a mention pill. -#' A user id with no textual occurrence is left to \code{m.mentions} alone, -#' which still notifies. +#' Turn textual @mentions into Matrix pills in formatted HTML #' #' @param html Character HTML (e.g. from \code{\link{mx_markdown_to_html}}). -#' @param user_ids Character vector of full Matrix user ids. -#' @return Character HTML with mention pills. -#' @examples -#' mx_pill_mentions("

ping @jorge

", "@jorge:example.org") +#' @param user_ids Character Matrix user ids, such as +#' \code{"@jorge:example.org"}. +#' @return HTML with textual \code{@localpart} occurrences replaced by +#' matrix.to links. Unmatched user ids leave the HTML unchanged; they +#' can still be placed in \code{m.mentions} by \code{mx_send_text()}. #' @export mx_pill_mentions <- function(html, user_ids) { - for (uid in user_ids) { + if (!length(user_ids)) { + return(html) + } + for (uid in unique(user_ids)) { local <- sub("^@([^:]+):.*$", "\\1", uid) - esc <- gsub("([][{}().*+?^$\\\\|])", "\\\\\\1", local) - pill <- sprintf("%s", uid, local) - # One pass matching @localpart with an optional :server tail -- a - # second pass would rescan the @id inside the inserted href. Case- - # insensitive since people type @Jorge for @jorge. + esc <- gsub("([][{}()+*^$.|\\\\?])", "\\\\\\1", local, perl = TRUE) + pill <- sprintf("%s", + mx_html_escape(uid), mx_html_escape(local)) html <- gsub(paste0("@", esc, "(:[A-Za-z0-9._-]+)?\\b"), pill, html, - ignore.case = TRUE) + perl = TRUE, ignore.case = TRUE) } html } diff --git a/R/tables.R b/R/tables.R new file mode 100644 index 0000000..e86f3f1 --- /dev/null +++ b/R/tables.R @@ -0,0 +1,94 @@ +# Matrix HTML table helpers. + +mx_table_coerce <- function(x) { + if (is.data.frame(x)) { + return(x) + } + if (is.matrix(x)) { + return(as.data.frame(x, stringsAsFactors = FALSE)) + } + if (is.list(x)) { + return(as.data.frame(x, stringsAsFactors = FALSE, check.names = FALSE)) + } + stop("x must be a data.frame, matrix, or list", call. = FALSE) +} + +mx_table_plain <- function(x) { + x <- mx_table_coerce(x) + header <- paste(names(x), collapse = " | ") + rows <- vapply(seq_len(nrow(x)), function(i) { + paste(vapply(x[i, , drop = FALSE], as.character, character(1)), + collapse = " | ") + }, character(1)) + paste(c(header, rows), collapse = "\n") +} + +#' Render tabular data as Matrix custom HTML +#' +#' Produces the conservative table shape rendered by Matrix clients such as +#' FluffyChat 2.6.0+: a bare \code{} containing \code{}, +#' \code{", + paste(vapply(names(x), cell, character(1), + tag = "th"), collapse = ""), + "")) + } + if (nrow(x)) { + rows <- c(rows, vapply(seq_len(nrow(x)), function(i) { + vals <- vapply(x[i, , drop = FALSE], as.character, character(1)) + paste0("", paste(vapply(vals, cell, character(1), tag = "td"), + collapse = ""), "") + }, character(1))) + } + paste0("
}, and \code{} nodes. No CSS, colspan, rowspan, or custom +#' attributes are emitted. +#' +#' @param x A data frame, matrix, or list coercible to a data frame. +#' @param header Logical. Include a header row using column names. +#' @return Character HTML suitable for Matrix \code{formatted_body}. +#' @examples +#' mx_table_html(data.frame(A = 1:2, B = c("x", "y"))) +#' @export +mx_table_html <- function(x, header = TRUE) { + x <- mx_table_coerce(x) + cell <- function(tag, value) { + sprintf("<%s>%s", tag, mx_html_escape(as.character(value)), tag) + } + rows <- character() + if (isTRUE(header)) { + rows <- c(rows, paste0("
", paste(rows, collapse = ""), "
") +} + +#' Send tabular data to a Matrix room +#' +#' Sends a plain-text fallback body plus Matrix custom HTML table in +#' \code{formatted_body}. This bypasses Markdown entirely. +#' +#' @param client Matrix client config. +#' @param x A data frame, matrix, or list coercible to a data frame. +#' @param room Character room id/name or NULL for the default room. +#' @param header Logical. Include a header row using column names. +#' @param title Optional text prepended to the plain fallback body. +#' @param room_cache Optional room name-to-id cache. +#' @param dry_run Logical. Print instead of sending. +#' @return Event id, or NULL on dry-run. +#' @examples +#' client <- list(room_id = "!default:example.org") +#' mx_send_table(client, data.frame(A = 1, B = 2), dry_run = TRUE) +#' @export +mx_send_table <- function(client, x, room = NULL, header = TRUE, title = NULL, + room_cache = NULL, dry_run = FALSE) { + html <- mx_table_html(x, header = header) + body <- mx_table_plain(x) + if (!is.null(title) && nzchar(title)) { + body <- paste(as.character(title), body, sep = "\n") + } + if (isTRUE(dry_run)) { + message("=== mx_send_table (dry-run) [", room %||% "default", + "] ===\n", body, "\n--- formatted_body ---\n", html) + return(invisible(NULL)) + } + rid <- mx_resolve_room(client, room, room_cache = room_cache) + mx.api::mx_send(mx_client_session(client), rid, body, msgtype = "m.text", + extra = list(format = "org.matrix.custom.html", + formatted_body = html)) +} diff --git a/inst/skills/mx.client/matrix-messaging/SKILL.md b/inst/skills/mx.client/matrix-messaging/SKILL.md new file mode 100644 index 0000000..92d5970 --- /dev/null +++ b/inst/skills/mx.client/matrix-messaging/SKILL.md @@ -0,0 +1,148 @@ +--- +name: matrix-messaging +description: > + Send and receive Matrix messages from R using the mx.* package family + (mx.api / mx.crypto / mx.client). Use when a user wants an R program or + agent to post to a Matrix room, read new messages, accept invites, send + files or tables, or talk to a Matrix homeserver. Posts go through + mx.client (config, room resolution, HTML formatting) over mx.api, never + hand-rolled curl. End-to-end encryption is orchestrated over the optional + mx.crypto package. +allowed-tools: Bash(r:*), Bash(Rscript:*), Read +--- + +# matrix-messaging + +Drive a Matrix homeserver from R with the mx.* family. `mx.client` owns +config persistence, room resolution, sync cursors, event extraction, and +HTML formatting; it sits on `mx.api` (the raw Client-Server endpoints) and, +for encryption, `mx.crypto` (Olm/Megolm primitives). + +Do not hand-roll curl against the homeserver. `mx.client` already owns +config loading, session construction, room resolution, and HTML formatting. + +## The package family + +| Package | Role | Notes | +|---|---|---| +| `mx.api` | HTTP transport, one function per CS-API endpoint, holds nothing | on CRAN; `mx.client` Imports it | +| `mx.crypto` | Olm/Megolm primitives (vodozemac via Rust) | on CRAN; `mx.client` Suggests it (E2EE only) | +| `mx.client` | stateful client: config, rooms, sync, formatting, E2EE orchestration | on CRAN | + +## First-time setup + +`mx_client_configure()` logs in, joins the room, and persists credentials +(mode 0600, under `tools::R_user_dir()`). `app` namespaces the stored +config so several bots can coexist. Do this once per identity. + +```r +mx.client::mx_client_configure( + server = "https://matrix.example.org", + user = "bot", + password = "secret", + room = "#general:example.org", # default room for later sends + app = "myapp" +) +``` + +## Send a message + +Every later session loads the stored config and sends. `markdown = TRUE` +adds a conservative HTML `formatted_body` (headings, bold, code, lists, +links, and GitHub pipe tables become Matrix `` HTML). `room` takes a +name or id; omit it for the configured default. `mx_send_text()` returns the +event id. + +```r +client <- mx.client::mx_client_load(app = "myapp") + +mx.client::mx_send_text(client, "hello from R") # default room +id <- mx.client::mx_send_text(client, "**shipped** `v0.1.1`", + room = "general", markdown = TRUE) # named room +``` + +Tokens stay in the config file. Never print them. + +To load a config by explicit path instead of `app`, pass `path =`. Use +`dry_run = TRUE` to print the resolved send without hitting the network. + +### Mentions + +`mentions = "@user:example.org"` adds the user to the event's `m.mentions` +(so they get pinged) and rewrites any textual `@localpart` in the body into a +matrix.to pill. A pill implies an HTML body even without `markdown = TRUE`. + +## Resolve rooms + +Send by human name instead of `!opaque:id`. `mx_resolve_room()` turns a name +into an id (or passes a literal `!id`/`#alias` through); `mx_room_lookup_by_name()` +lists the joined rooms, which is how you find a DM (the room whose members +are just the bot and one person). + +```r +room_id <- mx.client::mx_resolve_room(client, "general") +mx.client::mx_room_lookup_by_name(client) # name -> id table +``` + +## Send files and media + +`mx_send_media()` (needs `mx.api (>= 0.3.0)`) uploads and posts in one call. +The msgtype comes from the file's MIME type: a `.png` posts as `m.image`, a +`.mp4` as `m.video`. Server upload cap is about 20 MB +(`mx.api::mx_media_config()` to check). Pass `content_type =` for files whose +extension lies, and `body =` for a caption/filename. + +```r +mx.client::mx_send_media(client, "plot.png", room = "general") +``` + +## Send a table + +`mx_send_table()` renders a data frame (or matrix) straight to Matrix HTML +via `mx_table_html()`. + +```r +mx.client::mx_send_table(client, head(mtcars), room = "general") +``` + +## Receive: read new messages and advance the cursor + +`mx_sync_update()` long-polls and advances the stored sync cursor so you only +see new events. The `mx_extract_*` helpers parse the sync response. + +```r +res <- mx.client::mx_sync_update(client, timeout = 30000L) +msgs <- mx.client::mx_extract_text_events(res$sync, client$user_id) +invs <- mx.client::mx_extract_invites(res$sync) +mx.client::mx_accept_invites(client, invs) # join rooms you were invited to +``` + +## Survive token rotation + +`mx_with_relogin()` wraps any client operation: on `M_UNKNOWN_TOKEN` it +re-logs in with the stored password (keeping the device id, so an E2EE +identity survives), saves the refreshed token, and retries once. + +```r +mx.client::mx_with_relogin(client, function(cl) { + mx.client::mx_send_text(cl, "still here after a token rotation") +}) +``` + +## End-to-end encryption + +Olm/Megolm send/receive orchestrated over `mx.crypto`, aimed at bots and +controlled deployments. `mx.crypto` is a Suggests and is only touched from +the E2EE entry points, so plaintext clients install and run without a Rust +toolchain. Security model is trust-on-first-use (no cross-signing trust store +yet, no key-request flow). Check a room's state with `mx_room_encrypted()` +before choosing the encrypted or plaintext path. + +The full flow (store, account, key publish, `mx_send_encrypted()`, +`mx_crypto_process_sync()`) and its current limitations are in +`vignette("e2ee", package = "mx.client")`. + +## Report + +End with: which identity (app/config) posted, which room, the message, and +the returned `event_id`. diff --git a/inst/tinytest/test_markdown.R b/inst/tinytest/test_markdown.R index 3103e00..e0afdd0 100644 --- a/inst/tinytest/test_markdown.R +++ b/inst/tinytest/test_markdown.R @@ -30,3 +30,36 @@ expect_true(grepl("@jXr does not", p2, fixed = TRUE)) # no textual occurrence: html unchanged (m.mentions still notifies) expect_equal(mx.client::mx_pill_mentions("no names here", "@tiny:cornball.ai"), "no names here") + +# GitHub-style pipe tables become Matrix custom HTML tables. +tab <- mx.client::mx_markdown_to_html(paste(c( + "| Area | OKF | pensar |", + "|---|---|---|", + "| Links | Markdown links | `[[wikilinks]]` |", + "| Strictness | only **type** | title, type, source |" +), collapse = "\n")) +expect_true(grepl("
", tab, fixed = TRUE)) +expect_true(grepl("", tab, fixed = TRUE)) +expect_true(grepl("", tab, fixed = TRUE)) +expect_true(grepl("type", tab, fixed = TRUE)) + +# Known-good Matrix room shape from the Cornelius CRAN downloads report. +# This pins the exact format that rendered in-client: blank line before the +# pipe table, backticked package names, right-aligned numeric/date columns, +# and ordinary prose after the table. +known_good <- mx.client::mx_markdown_to_html(paste(c( + "**CRAN downloads weekly** (16 packages, as of 2026-07-20)", + "", + "| package | last week | last month | total |", + "|---|---:|---:|---:|", + "| `tinyoauth` | 63 | 543 | 601 |", + "| `saber` | 51 | 443 | 2,395 |", + "", + "* Partial period: package has not been on CRAN for the full week or month." +), collapse = "\n")) +expect_true(grepl("
Area[[wikilinks]]
", known_good, fixed = TRUE)) +expect_true(grepl("

CRAN downloads weekly", known_good, + fixed = TRUE)) +expect_true(grepl("

", known_good, fixed = TRUE)) +expect_true(grepl("", known_good, fixed = TRUE)) +expect_true(grepl("Partial period", known_good, fixed = TRUE)) diff --git a/inst/tinytest/test_tables.R b/inst/tinytest/test_tables.R new file mode 100644 index 0000000..f5e4950 --- /dev/null +++ b/inst/tinytest/test_tables.R @@ -0,0 +1,13 @@ +library(tinytest) + +html <- mx.client::mx_table_html(data.frame(A = c("1", ""), B = c("&", "two"))) +expect_equal(html, paste0( + "
tinyoauth2,395
", + "", + "", + "", + "
AB
1&
<x>two
")) + +expect_null(mx.client::mx_send_table(list(room_id = "!default:example.org"), + data.frame(A = 1, B = 2), + dry_run = TRUE)) diff --git a/man/mx_markdown_to_html.Rd b/man/mx_markdown_to_html.Rd index aa7dc5b..46a8120 100644 --- a/man/mx_markdown_to_html.Rd +++ b/man/mx_markdown_to_html.Rd @@ -13,7 +13,7 @@ Character HTML suitable for m.room.message formatted_body. } \description{ Supports headings, bullets, numbered lists, fenced code blocks, inline -code, bold, and simple underscore emphasis. +code, bold, simple underscore emphasis, and GitHub-style pipe tables. } \examples{ mx_markdown_to_html("# Status\n- built\n- checked\n\nShip `0.1.0` **soon**") diff --git a/man/mx_pill_mentions.Rd b/man/mx_pill_mentions.Rd index f8f716b..700e02c 100644 --- a/man/mx_pill_mentions.Rd +++ b/man/mx_pill_mentions.Rd @@ -1,25 +1,21 @@ % tinyrox says don't edit this manually, but it can't stop you! \name{mx_pill_mentions} \alias{mx_pill_mentions} -\title{Turn textual @mentions into matrix.to pills} +\title{Turn textual @mentions into Matrix pills in formatted HTML} \usage{ mx_pill_mentions(html, user_ids) } \arguments{ \item{html}{Character HTML (e.g. from \code{\link{mx_markdown_to_html}}).} -\item{user_ids}{Character vector of full Matrix user ids.} +\item{user_ids}{Character Matrix user ids, such as +\code{"@jorge:example.org"}.} } \value{ -Character HTML with mention pills. +HTML with textual \code{@localpart} occurrences replaced by + matrix.to links. Unmatched user ids leave the HTML unchanged; they + can still be placed in \code{m.mentions} by \code{mx_send_text()}. } \description{ -Replaces each occurrence of \code{@localpart} (or the full -\code{@localpart:server} id) in already-rendered HTML with a -\code{matrix.to} anchor, which Matrix clients render as a mention pill. -A user id with no textual occurrence is left to \code{m.mentions} alone, -which still notifies. -} -\examples{ -mx_pill_mentions("

ping @jorge

", "@jorge:example.org") +Turn textual @mentions into Matrix pills in formatted HTML } diff --git a/man/mx_send_table.Rd b/man/mx_send_table.Rd new file mode 100644 index 0000000..f97f146 --- /dev/null +++ b/man/mx_send_table.Rd @@ -0,0 +1,34 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_send_table} +\alias{mx_send_table} +\title{Send tabular data to a Matrix room} +\usage{ +mx_send_table(client, x, room = NULL, header = TRUE, title = NULL, + room_cache = NULL, dry_run = FALSE) +} +\arguments{ +\item{client}{Matrix client config.} + +\item{x}{A data frame, matrix, or list coercible to a data frame.} + +\item{room}{Character room id/name or NULL for the default room.} + +\item{header}{Logical. Include a header row using column names.} + +\item{title}{Optional text prepended to the plain fallback body.} + +\item{room_cache}{Optional room name-to-id cache.} + +\item{dry_run}{Logical. Print instead of sending.} +} +\value{ +Event id, or NULL on dry-run. +} +\description{ +Sends a plain-text fallback body plus Matrix custom HTML table in +\code{formatted_body}. This bypasses Markdown entirely. +} +\examples{ +client <- list(room_id = "!default:example.org") +mx_send_table(client, data.frame(A = 1, B = 2), dry_run = TRUE) +} diff --git a/man/mx_table_html.Rd b/man/mx_table_html.Rd new file mode 100644 index 0000000..fa2dc40 --- /dev/null +++ b/man/mx_table_html.Rd @@ -0,0 +1,24 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{mx_table_html} +\alias{mx_table_html} +\title{Render tabular data as Matrix custom HTML} +\usage{ +mx_table_html(x, header = TRUE) +} +\arguments{ +\item{x}{A data frame, matrix, or list coercible to a data frame.} + +\item{header}{Logical. Include a header row using column names.} +} +\value{ +Character HTML suitable for Matrix \code{formatted_body}. +} +\description{ +Produces the conservative table shape rendered by Matrix clients such as +FluffyChat 2.6.0+: a bare \code{} containing \code{}, +\code{
}, and \code{} nodes. No CSS, colspan, rowspan, or custom +attributes are emitted. +} +\examples{ +mx_table_html(data.frame(A = 1:2, B = c("x", "y"))) +} From 857bf4fa3f4dd6beb1f76ac816d57e41c9601b16 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Sun, 2 Aug 2026 09:09:11 -0500 Subject: [PATCH 2/4] Bump version to 0.1.1.2 --- DESCRIPTION | 2 +- NEWS.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 4f09ed4..ae28533 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: mx.client Type: Package Title: Stateful Matrix Client Helpers -Version: 0.1.1.1 +Version: 0.1.1.2 Date: 2026-06-13 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index 8852e82..65a327c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,15 @@ +# mx.client 0.1.1.2 + +* New: `mx_table_html()` and `mx_send_table()` render a data frame, + matrix, or list as the conservative Matrix table HTML that clients such + as FluffyChat 2.6.0+ accept: a bare `` of ``, `", known_good, fixed = TRUE)) expect_true(grepl("", known_good, fixed = TRUE)) expect_true(grepl("Partial period", known_good, fixed = TRUE)) + +# Ordered lists accept both "1." and "1)" markers. There was no coverage +# here, which is how a rewrite that dropped ")" support went unnoticed. +expect_equal(mx.client::mx_markdown_to_html("1. first\n2. second"), + "
  1. first
  2. second
") +expect_equal(mx.client::mx_markdown_to_html("1) first\n2) second"), + "
  1. first
  2. second
") diff --git a/inst/tinytest/test_tables.R b/inst/tinytest/test_tables.R index f5e4950..609b35c 100644 --- a/inst/tinytest/test_tables.R +++ b/inst/tinytest/test_tables.R @@ -11,3 +11,10 @@ expect_equal(html, paste0( expect_null(mx.client::mx_send_table(list(room_id = "!default:example.org"), data.frame(A = 1, B = 2), dry_run = TRUE)) + +# header = FALSE has to drop the header from both renderings, or the +# plain-text fallback shows a different table than formatted_body does. +d <- data.frame(A = c("1", "2"), B = c("x", "y")) +expect_equal(mx.client:::mx_table_plain(d, header = TRUE), "A | B\n1 | x\n2 | y") +expect_equal(mx.client:::mx_table_plain(d, header = FALSE), "1 | x\n2 | y") +expect_false(grepl("
`, and + `` nodes, with no CSS, colspan, rowspan, or custom attributes. + A plain-text `body` is generated alongside for clients that ignore + `formatted_body`. +* `mx_markdown_to_html()` converts GitHub-style pipe tables to the same + table HTML, honouring the `:---`/`:---:`/`---:` alignment row. +* New: `inst/skills/mx.client/matrix-messaging/SKILL.md`. + # mx.client 0.1.1.1 * `mx_extract_text_events()` keeps the event's `origin_server_ts` as a From 567c1a39f10caafdaaa643aba2617a63efe2add0 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Sun, 2 Aug 2026 09:10:12 -0500 Subject: [PATCH 3/4] rformat + document; restore dropped mx_pill_mentions example The markdown.R rewrite expanded mx_pill_mentions' @param/@return prose but dropped its @examples block, which tinyrox flags and CRAN wants on exported functions. Restored. rformat_dir also reflowed R/messages.R, which this branch never touched; the repo was not rformat-clean. --- R/markdown.R | 7 ++++--- R/messages.R | 10 ++++------ R/tables.R | 17 ++++++++--------- man/mx_pill_mentions.Rd | 3 +++ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/R/markdown.R b/R/markdown.R index 454f3ac..aa1b692 100644 --- a/R/markdown.R +++ b/R/markdown.R @@ -76,8 +76,7 @@ mx_table_to_html <- function(rows) { align <- mx_table_align(rows[[2]]) body <- rows[-c(1L, 2L)] html <- c("", "", - mx_table_html_cells(header, "th", align), - "") + mx_table_html_cells(header, "th", align), "") if (length(body)) { body_html <- vapply(body, function(row) { cells <- mx_table_row_cells(row) @@ -200,7 +199,7 @@ mx_markdown_to_html <- function(text) { next } out <- c(out, close_lists(), sprintf("

%s

", - mx_markdown_inline_html(ln))) + mx_markdown_inline_html(ln))) i <- i + 1L } if (in_pre) { @@ -218,6 +217,8 @@ mx_markdown_to_html <- function(text) { #' @return HTML with textual \code{@localpart} occurrences replaced by #' matrix.to links. Unmatched user ids leave the HTML unchanged; they #' can still be placed in \code{m.mentions} by \code{mx_send_text()}. +#' @examples +#' mx_pill_mentions("

ping @jorge

", "@jorge:example.org") #' @export mx_pill_mentions <- function(html, user_ids) { if (!length(user_ids)) { diff --git a/R/messages.R b/R/messages.R index 9ebf3f4..5da1f9a 100644 --- a/R/messages.R +++ b/R/messages.R @@ -218,19 +218,17 @@ mx_accept_invites <- function(client, invites) { #' target_event_id = "$msg") #' @export mx_extract_reaction_verdict <- function(sync_resp, room_id, self_id, - target_event_id, - approve_keys = NULL, + target_event_id, approve_keys = NULL, deny_keys = NULL) { # Emoji defaults are built here, not in the signature, so they don't # land as raw astral-plane glyphs in the .Rd \usage block -- LaTeX # can't typeset them and the PDF manual fails R CMD check --as-cran. if (is.null(approve_keys)) { - approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), - "y", "yes", "ok") + approve_keys <- c(intToUtf8(0x1F44D), intToUtf8(0x2705), "y", "yes", + "ok") } if (is.null(deny_keys)) { - deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), - "n", "no", "nope") + deny_keys <- c(intToUtf8(0x1F44E), intToUtf8(0x274C), "n", "no", "nope") } room <- sync_resp$rooms$join[[room_id]] if (is.null(room)) { diff --git a/R/tables.R b/R/tables.R index e86f3f1..7effd6c 100644 --- a/R/tables.R +++ b/R/tables.R @@ -17,7 +17,7 @@ mx_table_plain <- function(x) { x <- mx_table_coerce(x) header <- paste(names(x), collapse = " | ") rows <- vapply(seq_len(nrow(x)), function(i) { - paste(vapply(x[i, , drop = FALSE], as.character, character(1)), + paste(vapply(x[i,, drop = FALSE], as.character, character(1)), collapse = " | ") }, character(1)) paste(c(header, rows), collapse = "\n") @@ -44,15 +44,15 @@ mx_table_html <- function(x, header = TRUE) { rows <- character() if (isTRUE(header)) { rows <- c(rows, paste0("", - paste(vapply(names(x), cell, character(1), - tag = "th"), collapse = ""), + paste(vapply(names(x), cell, character(1), tag = "th"), + collapse = ""), "")) } if (nrow(x)) { rows <- c(rows, vapply(seq_len(nrow(x)), function(i) { - vals <- vapply(x[i, , drop = FALSE], as.character, character(1)) + vals <- vapply(x[i,, drop = FALSE], as.character, character(1)) paste0("", paste(vapply(vals, cell, character(1), tag = "td"), - collapse = ""), "") + collapse = ""), "") }, character(1))) } paste0("
", paste(rows, collapse = ""), "
") @@ -75,8 +75,8 @@ mx_table_html <- function(x, header = TRUE) { #' client <- list(room_id = "!default:example.org") #' mx_send_table(client, data.frame(A = 1, B = 2), dry_run = TRUE) #' @export -mx_send_table <- function(client, x, room = NULL, header = TRUE, title = NULL, - room_cache = NULL, dry_run = FALSE) { +mx_send_table <- function(client, x, room = NULL, header = TRUE, + title = NULL, room_cache = NULL, dry_run = FALSE) { html <- mx_table_html(x, header = header) body <- mx_table_plain(x) if (!is.null(title) && nzchar(title)) { @@ -89,6 +89,5 @@ mx_send_table <- function(client, x, room = NULL, header = TRUE, title = NULL, } rid <- mx_resolve_room(client, room, room_cache = room_cache) mx.api::mx_send(mx_client_session(client), rid, body, msgtype = "m.text", - extra = list(format = "org.matrix.custom.html", - formatted_body = html)) + extra = list(format = "org.matrix.custom.html", formatted_body = html)) } diff --git a/man/mx_pill_mentions.Rd b/man/mx_pill_mentions.Rd index 700e02c..499a964 100644 --- a/man/mx_pill_mentions.Rd +++ b/man/mx_pill_mentions.Rd @@ -19,3 +19,6 @@ HTML with textual \code{@localpart} occurrences replaced by \description{ Turn textual @mentions into Matrix pills in formatted HTML } +\examples{ +mx_pill_mentions("

ping @jorge

", "@jorge:example.org") +} From 5c8a8ac45d0b8509ada6d441d8180fc3f98e8745 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Mon, 3 Aug 2026 13:34:34 -0500 Subject: [PATCH 4/4] Fix header=FALSE fallback and restore 1) ordered-list support mx_send_table(header = FALSE) dropped the header from the HTML but mx_table_plain() always emitted column names, so formatted_body and the plain-text body showed different tables. mx_table_plain() now takes header and mx_send_table() passes it through. The markdown rewrite matched only '1.' for ordered lists; main matched '[.)]'. '1)' input silently rendered as a paragraph. Restored, and both markers are now covered -- there were no ordered-list tests at all, which is how the regression got through. --- R/markdown.R | 4 +++- R/tables.R | 12 ++++++++---- inst/tinytest/test_markdown.R | 7 +++++++ inst/tinytest/test_tables.R | 7 +++++++ 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/R/markdown.R b/R/markdown.R index aa1b692..4df1bcd 100644 --- a/R/markdown.R +++ b/R/markdown.R @@ -182,7 +182,9 @@ mx_markdown_to_html <- function(text) { i <- i + 1L next } - o <- regexec("^\\s*[0-9]+\\.\\s+(.+)$", ln, perl = TRUE) + # Both "1." and "1)" are ordered-list markers; matching only the + # dot silently renders "1)" input as a paragraph. + o <- regexec("^\\s*[0-9]+[.)]\\s+(.+)$", ln, perl = TRUE) om <- regmatches(ln, o)[[1]] if (length(om)) { if (in_ul) { diff --git a/R/tables.R b/R/tables.R index 7effd6c..3a1b3a8 100644 --- a/R/tables.R +++ b/R/tables.R @@ -13,14 +13,18 @@ mx_table_coerce <- function(x) { stop("x must be a data.frame, matrix, or list", call. = FALSE) } -mx_table_plain <- function(x) { +mx_table_plain <- function(x, header = TRUE) { x <- mx_table_coerce(x) - header <- paste(names(x), collapse = " | ") rows <- vapply(seq_len(nrow(x)), function(i) { paste(vapply(x[i,, drop = FALSE], as.character, character(1)), collapse = " | ") }, character(1)) - paste(c(header, rows), collapse = "\n") + # Track the HTML: emitting column names in the fallback while the + # formatted_body omits them shows different tables to different clients. + if (isTRUE(header)) { + rows <- c(paste(names(x), collapse = " | "), rows) + } + paste(rows, collapse = "\n") } #' Render tabular data as Matrix custom HTML @@ -78,7 +82,7 @@ mx_table_html <- function(x, header = TRUE) { mx_send_table <- function(client, x, room = NULL, header = TRUE, title = NULL, room_cache = NULL, dry_run = FALSE) { html <- mx_table_html(x, header = header) - body <- mx_table_plain(x) + body <- mx_table_plain(x, header = header) if (!is.null(title) && nzchar(title)) { body <- paste(as.character(title), body, sep = "\n") } diff --git a/inst/tinytest/test_markdown.R b/inst/tinytest/test_markdown.R index e0afdd0..1c1f72b 100644 --- a/inst/tinytest/test_markdown.R +++ b/inst/tinytest/test_markdown.R @@ -63,3 +63,10 @@ expect_true(grepl("

CRAN downloads weekly", known_good, expect_true(grepl("

tinyoauth2,395", mx.client::mx_table_html(d, header = FALSE)))