Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 11 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
8 changes: 6 additions & 2 deletions R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion R/irc.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
3 changes: 2 additions & 1 deletion R/loopback.R
Original file line number Diff line number Diff line change
Expand Up @@ -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_)
}
Expand Down
2 changes: 1 addition & 1 deletion R/matrix.R
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
108 changes: 84 additions & 24 deletions R/slack.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)) &&
Expand All @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]])) {
Expand All @@ -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
}

Expand Down
3 changes: 2 additions & 1 deletion R/telegram.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions inst/tinytest/test_contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
88 changes: 88 additions & 0 deletions inst/tinytest/test_slack.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions man/chat_capabilities.Rd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading