From 7745cf2b9e88ab16d58ff472e565af104d4fab71 Mon Sep 17 00:00:00 2001 From: jorge_krzyzaniak Date: Tue, 8 Sep 2026 13:19:33 -0500 Subject: [PATCH 1/3] Slack: authenticate as a real member with a user token chat_slack()'s token is a bot token, and chat_send()'s identity/ username only relabel that bot's own post with a cosmetic name and icon -- there was no way to actually post or resolve identity as a real workspace member. Add an optional user_token (xoxp-...) to chat_slack(), and an as_user = TRUE flag on chat_send()/chat_whoami() that authenticates with it instead of the bot token, so a send shows the member's real name and photo. Confined to the Slack adapter; identity/username and as_user are separate mechanisms and chat_capabilities()$user_identity reports whether a given client is configured for the latter. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01F1QZ4ESCCu2V94jus4rzM3 --- NEWS.md | 8 ++++ R/slack.R | 97 ++++++++++++++++++++++++++++---------- inst/tinytest/test_slack.R | 83 ++++++++++++++++++++++++++++++++ man/chat_slack.Rd | 16 +++++++ 4 files changed, 180 insertions(+), 24 deletions(-) diff --git a/NEWS.md b/NEWS.md index 12596a2..a33e108 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,13 @@ # chat.api 0.0.1.27 +* 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 client + was configured with a user token. + * New Telegram adapter, chat_telegram(), over the Bot API with HTTP delegated to the suggested httr package. getUpdates long polling is the poll, with a single update offset as the cursor. Sends render diff --git a/R/slack.R b/R/slack.R index e92df50..2c99fe9 100644 --- a/R/slack.R +++ b/R/slack.R @@ -27,9 +27,24 @@ #' \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. +#' #' @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 +62,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 +78,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 +193,38 @@ 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) and are not sent here. + args <- if (isTRUE(as_user)) { + list(txt = slack_render(text, markup), + channel = sub("^#", "", channel), + token = client$user_token) + } 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 +449,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 +494,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/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index 17c3347..18c2359 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -519,6 +519,89 @@ 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) + expect_identical(ts, "999.1") + expect_identical(seen$token, "xoxp-user") + expect_false("username" %in% names(seen)) + expect_false("icon_emoji" %in% names(seen)) +}) + +# 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_slack.Rd b/man/chat_slack.Rd index a74ca34..15accaa 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,13 @@ 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. } From 09fc4f3f8121af197e9d2ad5c2dfe2149b02628b Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 8 Sep 2026 13:38:35 -0500 Subject: [PATCH 2/3] Review fixes for the Slack user-token path - Send username and icon_emoji as empty strings on the as_user path too. Omitted, slackr fills them from SLACK_USERNAME and SLACK_ICON_EMOJI, which is exactly what the bot path suppresses. - Every adapter answers user_identity, FALSE outside Slack, and the contract loop checks it: a consumer reading NULL cannot tell an adapter with no such thing from a client not given a token. The flag is documented on chat_capabilities(). - Note in chat_slack()'s docs that an as_user post is the member's own message to this client's poll as well, since Slack carries no self. - Regenerate man pages with the repo's tinyrox. --- R/contract.R | 8 ++++++-- R/irc.R | 3 ++- R/loopback.R | 3 ++- R/matrix.R | 2 +- R/slack.R | 15 +++++++++++++-- R/telegram.R | 3 ++- inst/tinytest/test_contract.R | 17 +++++++++++++++++ inst/tinytest/test_slack.R | 11 ++++++++--- man/chat_capabilities.Rd | 8 ++++++-- man/chat_slack.Rd | 7 +++++++ 10 files changed, 64 insertions(+), 13 deletions(-) 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 2c99fe9..3fe1e8d 100644 --- a/R/slack.R +++ b/R/slack.R @@ -37,6 +37,12 @@ #' 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. @@ -201,11 +207,16 @@ chat_send.chat_slack <- function(client, channel, text, } # 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) and are not sent here. + # 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) + token = client$user_token, + username = "", icon_emoji = "") } else { name_override <- if (is.null(identity$name)) { client$username 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 18c2359..ff2ebbf 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -541,11 +541,16 @@ local({ } 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) + ts <- chat_send(cl, "lab", "as me", as_user = TRUE, thread = "42.1") expect_identical(ts, "999.1") expect_identical(seen$token, "xoxp-user") - expect_false("username" %in% names(seen)) - expect_false("icon_emoji" %in% names(seen)) + # 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 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 15accaa..e0c51bb 100644 --- a/man/chat_slack.Rd +++ b/man/chat_slack.Rd @@ -70,4 +70,11 @@ 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. + } From 575ff6621b19040bba3a36b2395b45469b430e82 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 8 Sep 2026 13:38:35 -0500 Subject: [PATCH 3/3] Bump version to 0.0.1.28 --- DESCRIPTION | 4 ++-- NEWS.md | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) 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 a33e108..a912617 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,12 +1,15 @@ -# chat.api 0.0.1.27 +# 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 client - was configured with a user token. + 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 delegated to the suggested httr package. getUpdates long polling is