diff --git a/DESCRIPTION b/DESCRIPTION index 02147d3..b7ad052 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,8 +1,8 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.27 -Date: 2026-09-07 +Version: 0.0.1.28 +Date: 2026-09-08 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NEWS.md b/NEWS.md index 12596a2..a912617 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,14 @@ +# chat.api 0.0.1.28 + +* Slack gains user-token identity: chat_slack(user_token =) plus + as_user = TRUE on chat_send() and chat_whoami() authenticate as an + actual workspace member instead of the bot, so a send shows up under + that member's real name and photo rather than the bot's profile. + Distinct from identity/username, which only relabels the bot's own + post. Capability flag user_identity reports whether a given Slack + client was configured with a user token; every other adapter reports + FALSE. + # chat.api 0.0.1.27 * New Telegram adapter, chat_telegram(), over the Bot API with HTTP diff --git a/R/contract.R b/R/contract.R index 96ad60f..ac06bc3 100644 --- a/R/contract.R +++ b/R/contract.R @@ -126,8 +126,12 @@ chat_resolve <- function(client, name, ...) { #' media comes back out of \code{\link{chat_poll}} as #' \code{\link{chat_attachment}} records), \code{typing}, #' \code{e2ee}, \code{identity_override} (logicals), -#' \code{markup_dialects} (character), \code{max_message_bytes} -#' (integer or NA). +#' \code{user_identity} (a send can authenticate as a real member of +#' the platform rather than as the bot -- Slack's +#' \code{chat_send(as_user = TRUE)} with a user token; a property of +#' the client instance's configuration there, and FALSE everywhere +#' else), \code{markup_dialects} (character), +#' \code{max_message_bytes} (integer or NA). #' #' Sending and receiving get separate flags wherever a platform does #' one and not the other, which is why \code{threads} and diff --git a/R/irc.R b/R/irc.R index 9b495b5..c4a2fce 100644 --- a/R/irc.R +++ b/R/irc.R @@ -133,7 +133,8 @@ chat_capabilities.chat_irc <- function(client, ...) { # has no join verb yet, so neither flag can be TRUE honestly. channel_create = FALSE, leave = FALSE, set_state = FALSE, files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = FALSE, rich_markup = character(), + identity_override = FALSE, user_identity = FALSE, + rich_markup = character(), markup_dialects = "plain", max_message_bytes = 400L) } diff --git a/R/loopback.R b/R/loopback.R index 496c065..33fc55f 100644 --- a/R/loopback.R +++ b/R/loopback.R @@ -151,7 +151,8 @@ chat_capabilities.chat_loopback <- function(client, ...) { # them back out of the poll. Both TRUE is what makes loopback # the round-trip test double for media-carrying consumers. files = TRUE, attachments = TRUE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, rich_markup = character(), + identity_override = TRUE, user_identity = FALSE, + rich_markup = character(), markup_dialects = c("plain", "markdown"), max_message_bytes = NA_integer_) } diff --git a/R/matrix.R b/R/matrix.R index ad6b400..18ec908 100644 --- a/R/matrix.R +++ b/R/matrix.R @@ -972,7 +972,7 @@ chat_capabilities.chat_matrix <- function(client, ...) { # unmentioned. attachments = matrix_media_capable(client), typing = TRUE, e2ee = isTRUE(client$e2ee), - identity_override = FALSE, + identity_override = FALSE, user_identity = 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", diff --git a/R/slack.R b/R/slack.R index e92df50..3fe1e8d 100644 --- a/R/slack.R +++ b/R/slack.R @@ -27,9 +27,30 @@ #' \code{chat_send(identity =)} (both need the chat:write.customize #' scope). #' +#' \code{user_token} is a different kind of authorship than +#' \code{identity}: \code{identity}/\code{username} relabel a bot's own +#' post with a cosmetic name and icon, while a user token +#' (\code{xoxp-...}, obtained via a Slack app's User Token Scopes +#' rather than its Bot Token Scopes) authenticates as an actual +#' workspace member, so \code{chat_send(..., as_user = TRUE)} and +#' \code{chat_whoami(..., as_user = TRUE)} post and resolve identity as +#' that member -- Slack shows their real name and photo, not a bot +#' profile. Optional: leave unset if you only ever post as the bot. +#' +#' A post made \code{as_user = TRUE} is the member's own message in +#' every respect, including to this client's own \code{\link{chat_poll}}: +#' Slack messages carry no \code{self}, so nothing distinguishes it from +#' something the member typed, and a consumer that replies to the +#' member's traffic will reply to it. +#' #' @param channels Character vector of channels to poll. #' @param token Bot token; defaults to the \code{SLACK_TOKEN} #' environment variable. +#' @param user_token User token (\code{xoxp-...}) for posting and +#' resolving identity as a real workspace member rather than the bot; +#' defaults to the \code{SLACK_USER_TOKEN} environment variable. +#' Optional -- leave unset (empty string) if \code{as_user} is never +#' used. #' @param username Default display-name override for sends, or NULL #' (default) to post as the bot's own identity. #' @param .history Testing seam: replacement for @@ -47,7 +68,9 @@ #' @return A \code{chat_client} of class \code{chat_slack}. #' @export chat_slack <- function(channels = character(), - token = Sys.getenv("SLACK_TOKEN"), username = NULL, + token = Sys.getenv("SLACK_TOKEN"), + user_token = Sys.getenv("SLACK_USER_TOKEN"), + username = NULL, .history = NULL, .post = NULL, .react = NULL, .api = NULL) { if ((is.null(.history) || is.null(.post)) && @@ -61,7 +84,7 @@ chat_slack <- function(channels = character(), env <- new.env(parent = emptyenv()) env$cursor <- list() structure(list(env = env, channels = sub("^#", "", channels), - token = token, username = username, + token = token, user_token = user_token, username = username, history_fn = .history %||% slackr::slackr_history, post_fn = .post %||% slackr::slackr_msg, react_fn = .react, api_fn = .api), @@ -176,25 +199,43 @@ chat_send.chat_slack <- function(client, channel, text, thread = NULL, reply_to = NULL, identity = NULL, files = NULL, kind = "message", notify = TRUE, - rich = NULL, ...) { + rich = NULL, as_user = FALSE, ...) { markup <- match.arg(markup) - name_override <- if (is.null(identity$name)) { - client$username - } else { - identity$name + if (isTRUE(as_user) && !nzchar(client$user_token %||% "")) { + stop("chat_send(as_user = TRUE) needs a user token ", + "(SLACK_USER_TOKEN).", call. = FALSE) } - # Empty strings suppress slackr's SLACK_USERNAME/SLACK_ICON_EMOJI - # env defaults (the same value an unset variable produces), so an - # ordinary send never silently inherits a customized identity - args <- list(txt = slack_render(text, markup), - channel = sub("^#", "", channel), - token = client$token, - username = name_override %||% "", - icon_emoji = if (is.null(identity$icon)) { - "" + # A user-token send authenticates as an actual member, so there is + # no bot identity left to relabel -- username/icon_emoji are a + # bot-only concept (chat:write.customize). They still go out as + # empty strings, for the same reason the bot path sends them: + # omitted, slackr fills them from SLACK_USERNAME/SLACK_ICON_EMOJI, + # and whether Slack ignores those on a user token is not something + # this adapter should have to know. + args <- if (isTRUE(as_user)) { + list(txt = slack_render(text, markup), + channel = sub("^#", "", channel), + token = client$user_token, + username = "", icon_emoji = "") + } else { + name_override <- if (is.null(identity$name)) { + client$username } else { - identity$icon - }) + identity$name + } + # Empty strings suppress slackr's SLACK_USERNAME/SLACK_ICON_EMOJI + # env defaults (the same value an unset variable produces), so an + # ordinary send never silently inherits a customized identity + list(txt = slack_render(text, markup), + channel = sub("^#", "", channel), + token = client$token, + username = name_override %||% "", + icon_emoji = if (is.null(identity$icon)) { + "" + } else { + identity$icon + }) + } if (identical(markup, "plain")) { # Rides slackr_msg's ... into the chat.postMessage body: # without it Slack styles *emphasis* even in "plain" text @@ -419,24 +460,43 @@ chat_capabilities.chat_slack <- function(client, ...) { mark_read = TRUE, set_identity = TRUE, relogin = FALSE, channel_create = TRUE, leave = TRUE, set_state = FALSE, files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, - identity_override = TRUE, rich_markup = character(), + identity_override = TRUE, + # Unlike every other flag here, this one is a property of the + # instance, not the adapter: whether as_user = TRUE will work + # depends on whether this client was built with a user_token, + # the same way a missing bot token already fails at + # construction rather than through a capability flag. It is + # surfaced here anyway because there is no construction-time + # failure to fail with -- a client is free to never use + # as_user, and unconfigured is a normal, valid state for it. + user_identity = nzchar(client$user_token %||% ""), + rich_markup = character(), markup_dialects = c("plain", "markdown"), max_message_bytes = 40000L) } #' @export -chat_whoami.chat_slack <- function(client, ...) { +chat_whoami.chat_slack <- function(client, as_user = FALSE, ...) { # Cached for the client's lifetime. Unlike Matrix, where the id is # already a field of the config in hand, Slack only answers this over # the network -- and chat_addressed() asks once per message. The # answer is a property of the token, which cannot change underneath a # client that was constructed with it. - if (!is.null(client$env$whoami)) { - return(client$env$whoami) + # + # A client can hold two identities now (bot token, user token), so + # each gets its own cache slot rather than sharing one. + cache_slot <- if (isTRUE(as_user)) "whoami_user" else "whoami" + if (!is.null(client$env[[cache_slot]])) { + return(client$env[[cache_slot]]) + } + token <- if (isTRUE(as_user)) client$user_token else client$token + if (isTRUE(as_user) && !nzchar(token %||% "")) { + stop("chat_whoami(as_user = TRUE) needs a user token ", + "(SLACK_USER_TOKEN).", call. = FALSE) } api <- client$api_fn %||% slackr::call_slack_api body <- slack_body(api("/api/auth.test", .method = "GET", - token = client$token)) + token = token)) slack_stop_for_error(body, "auth.test") id <- body$user_id if (is.null(id) || !length(id) || !nzchar(as.character(id)[[1L]])) { @@ -445,7 +505,7 @@ chat_whoami.chat_slack <- function(client, ...) { who <- chat_identity(as.character(id)[[1L]], display = body$user %||% NA_character_, raw = body) - client$env$whoami <- who + client$env[[cache_slot]] <- who who } diff --git a/R/telegram.R b/R/telegram.R index 43d88d4..39203b5 100644 --- a/R/telegram.R +++ b/R/telegram.R @@ -573,7 +573,8 @@ chat_capabilities.chat_telegram <- function(client, ...) { mark_read = FALSE, set_identity = TRUE, relogin = FALSE, channel_create = FALSE, leave = TRUE, set_state = FALSE, files = TRUE, attachments = TRUE, typing = TRUE, e2ee = FALSE, - identity_override = FALSE, rich_markup = "html", + identity_override = FALSE, user_identity = FALSE, + rich_markup = "html", markup_dialects = c("plain", "markdown"), # 4096 characters after entities parsing. Characters, not # bytes, so a message of multibyte text hits it sooner than diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index 5478ed1..a88f3ff 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -457,6 +457,23 @@ local({ } }) +# ---- Every adapter answers user_identity ---- +# Slack's is a property of the client instance (was it built with a +# user token), which is why it is worth checking that the others say +# FALSE rather than nothing: a consumer that reads NULL cannot tell +# "this adapter has no such thing" from "this client was not given +# one". The stub client here has no user_token, so Slack answers FALSE +# too; its TRUE case is in test_slack.R. +local({ + for (adapter in c("chat_loopback", "chat_irc", "chat_slack", + "chat_matrix", "chat_telegram")) { + m <- getS3method("chat_capabilities", adapter) + caps <- m(structure(list(env = new.env()), class = adapter)) + expect_true("user_identity" %in% names(caps), info = adapter) + expect_false(caps$user_identity, info = adapter) + } +}) + # ---- Attachment record ---- att <- chat_attachment("mxc://ex/abc", name = "plot.png", mime = "image/png", bytes = 1024L) diff --git a/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index 17c3347..ff2ebbf 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -519,6 +519,94 @@ local({ expect_true(chat_capabilities(slack_api_client(function(...) NULL))$whoami) +# ---- User-token identity ---- +# as_user authenticates as an actual workspace member (a user token) +# rather than relabeling the bot's own post (identity/username, which +# stays a cosmetic override on the bot's send). The two are different +# mechanisms and get separate tests. + +local({ + cl <- chat_slack(channels = "lab", token = "xoxb-bot", user_token = "xoxp-user", + .history = function(...) NULL, .post = function(...) "1") + expect_identical(cl$user_token, "xoxp-user") +}) + +# A user-token send authenticates as the member, so there is no bot +# identity left to relabel: username/icon_emoji are not sent. +local({ + seen <- NULL + fake_post <- function(...) { + seen <<- list(...) + list(ok = TRUE, ts = "999.1") + } + cl <- chat_slack(channels = "lab", token = "xoxb-bot", user_token = "xoxp-user", + .history = function(...) NULL, .post = fake_post) + ts <- chat_send(cl, "lab", "as me", as_user = TRUE, thread = "42.1") + expect_identical(ts, "999.1") + expect_identical(seen$token, "xoxp-user") + # Sent as empty strings rather than omitted: omitted, slackr fills + # them from SLACK_USERNAME/SLACK_ICON_EMOJI, exactly what the bot + # path suppresses the same way. + expect_identical(seen$username, "") + expect_identical(seen$icon_emoji, "") + # Threads ride the same parameter on either token. + expect_identical(seen$thread_ts, "42.1") +}) + +# Asking to post as a member this client was never given a user token +# for is a configuration error, not a silent fall-back to the bot. +local({ + cl <- chat_slack(channels = "lab", token = "xoxb-bot", + .history = function(...) NULL, + .post = function(...) list(ok = TRUE, ts = "1")) + expect_error(chat_send(cl, "lab", "as me", as_user = TRUE), "user token") +}) + +# chat_whoami(as_user = TRUE) resolves the member's identity, cached +# separately from the bot's -- a client can legitimately hold both. +local({ + calls <- 0L + api <- function(path, ..., .method, token) { + calls <<- calls + 1L + if (identical(token, "xoxp-user")) { + list(ok = TRUE, user_id = "U0HUMAN", user = "jorge") + } else { + list(ok = TRUE, user_id = "U0BOT", user = "little-j") + } + } + cl <- chat_slack(channels = "lab", token = "xoxb-bot", user_token = "xoxp-user", + .history = function(...) NULL, .post = function(...) "1", .api = api) + bot <- chat_whoami(cl) + me <- chat_whoami(cl, as_user = TRUE) + expect_identical(bot$id, "U0BOT") + expect_identical(me$id, "U0HUMAN") + expect_identical(calls, 2L) + # Each identity is cached on its own slot: neither call repeats. + chat_whoami(cl) + chat_whoami(cl, as_user = TRUE) + expect_identical(calls, 2L) +}) + +# chat_whoami(as_user = TRUE) on a client with no user token is the +# same configuration error as the send path. +local({ + cl <- chat_slack(channels = "lab", token = "xoxb-bot", + .history = function(...) NULL, .post = function(...) "1", + .api = function(...) list(ok = TRUE, user_id = "U0BOT")) + expect_error(chat_whoami(cl, as_user = TRUE), "user token") +}) + +# The capability is a property of this instance's configuration, not a +# static fact about the Slack adapter -- unlike every other flag here. +local({ + with_ut <- chat_slack(channels = "lab", token = "t", user_token = "xoxp-x", + .history = function(...) NULL, .post = function(...) "1") + without_ut <- chat_slack(channels = "lab", token = "t", user_token = "", + .history = function(...) NULL, .post = function(...) "1") + expect_true(chat_capabilities(with_ut)$user_identity) + expect_false(chat_capabilities(without_ut)$user_identity) +}) + # ---- History paging ---- local({ seen <- NULL diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index 310c706..9ffc396 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -31,8 +31,12 @@ A list with at least: \code{threads} (can post into media comes back out of \code{\link{chat_poll}} as \code{\link{chat_attachment}} records), \code{typing}, \code{e2ee}, \code{identity_override} (logicals), - \code{markup_dialects} (character), \code{max_message_bytes} - (integer or NA). + \code{user_identity} (a send can authenticate as a real member of + the platform rather than as the bot -- Slack's + \code{chat_send(as_user = TRUE)} with a user token; a property of + the client instance's configuration there, and FALSE everywhere + else), \code{markup_dialects} (character), + \code{max_message_bytes} (integer or NA). Sending and receiving get separate flags wherever a platform does one and not the other, which is why \code{threads} and diff --git a/man/chat_slack.Rd b/man/chat_slack.Rd index a74ca34..e0c51bb 100644 --- a/man/chat_slack.Rd +++ b/man/chat_slack.Rd @@ -6,6 +6,7 @@ chat_slack( channels = character(), token = Sys.getenv("SLACK_TOKEN"), + user_token = Sys.getenv("SLACK_USER_TOKEN"), username = NULL, .history = NULL, .post = NULL, @@ -19,6 +20,12 @@ chat_slack( \item{token}{Bot token; defaults to the \code{SLACK_TOKEN} environment variable.} +\item{user_token}{User token (\code{xoxp-...}) for posting and +resolving identity as a real workspace member rather than the bot; +defaults to the \code{SLACK_USER_TOKEN} environment variable. +Optional -- leave unset (empty string) if \code{as_user} is never +used.} + \item{username}{Default display-name override for sends, or NULL (default) to post as the bot's own identity.} @@ -54,4 +61,20 @@ is only overridden through \code{username} here or \code{chat_send(identity =)} (both need the chat:write.customize scope). +\code{user_token} is a different kind of authorship than +\code{identity}: \code{identity}/\code{username} relabel a bot's own +post with a cosmetic name and icon, while a user token +(\code{xoxp-...}, obtained via a Slack app's User Token Scopes +rather than its Bot Token Scopes) authenticates as an actual +workspace member, so \code{chat_send(..., as_user = TRUE)} and +\code{chat_whoami(..., as_user = TRUE)} post and resolve identity as +that member -- Slack shows their real name and photo, not a bot +profile. Optional: leave unset if you only ever post as the bot. + +A post made \code{as_user = TRUE} is the member's own message in +every respect, including to this client's own \code{\link{chat_poll}}: +Slack messages carry no \code{self}, so nothing distinguishes it from +something the member typed, and a consumer that replies to the +member's traffic will reply to it. + }