diff --git a/DESCRIPTION b/DESCRIPTION index e03c9e0..b654a58 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.25 -Date: 2026-09-04 +Version: 0.0.1.26 +Date: 2026-09-07 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NAMESPACE b/NAMESPACE index b38b00e..8c5f1d1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -49,6 +49,7 @@ S3method(chat_capabilities,chat_matrix) S3method(chat_capabilities,chat_slack) S3method(chat_channel_create,chat_loopback) S3method(chat_channel_create,chat_matrix) +S3method(chat_channel_create,chat_slack) S3method(chat_channel_create,default) S3method(chat_channel_info,chat_matrix) S3method(chat_channel_info,chat_slack) @@ -77,6 +78,7 @@ S3method(chat_join,chat_matrix) S3method(chat_join,chat_slack) S3method(chat_join,default) S3method(chat_leave,chat_matrix) +S3method(chat_leave,chat_slack) S3method(chat_leave,default) S3method(chat_mark_read,chat_matrix) S3method(chat_mark_read,chat_slack) diff --git a/NEWS.md b/NEWS.md index 925f1fa..4a6a6aa 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# chat.api 0.0.1.26 + +* Slack gains chat_channel_create() and chat_leave(), posting + conversations.create and conversations.leave through the adapter's API + seam. Adapter options such as is_private = TRUE pass through to the + request body, and Slack's own refusals (name_taken, invalid_name, + not_in_channel) propagate as errors. Both capability flags are now TRUE. + # chat.api 0.0.1.25 * Matrix E2EE saves ratchet state before room-key request transport, retries diff --git a/R/slack.R b/R/slack.R index ad34be7..e92df50 100644 --- a/R/slack.R +++ b/R/slack.R @@ -317,6 +317,36 @@ chat_join.chat_slack <- function(client, channel, ...) { invisible(sub("^#", "", channel)) } +#' @export +chat_channel_create.chat_slack <- function(client, name, ...) { + # conversations.create. The name is normalized the way every other + # channel argument here is; Slack's own naming rules (lowercase, no + # spaces or periods) are enforced by the API and its refusal + # propagates. Adapter options (is_private = TRUE) ride through ... + # into the request body. The created channel is NOT added to the + # poll set: channels= is fixed at construction, the same bargain + # chat_join() makes. + api <- client$api_fn %||% slackr::call_slack_api + body <- slack_body(api("/api/conversations.create", .method = "POST", + token = client$token, + body = list(name = sub("^#", "", name), ...))) + slack_stop_for_error(body, "conversations.create") + invisible(as.character(body$channel$name %||% body$channel$id)) +} + +#' @export +chat_leave.chat_slack <- function(client, channel, ...) { + # conversations.leave. A refusal (not_in_channel) propagates: a + # leave that quietly failed keeps delivering a channel the caller + # believes it has left. + api <- client$api_fn %||% slackr::call_slack_api + body <- slack_body(api("/api/conversations.leave", .method = "POST", + token = client$token, + body = list(channel = sub("^#", "", channel)))) + slack_stop_for_error(body, "conversations.leave") + invisible(sub("^#", "", channel)) +} + #' @export chat_members.chat_slack <- function(client, channel, ...) { api <- client$api_fn %||% slackr::call_slack_api @@ -387,9 +417,7 @@ chat_capabilities.chat_slack <- function(client, ...) { invites = FALSE, join = TRUE, whoami = TRUE, channels = TRUE, history = TRUE, pending = FALSE, mark_read = TRUE, set_identity = TRUE, relogin = FALSE, - # conversations.create and conversations.leave exist in the - # Web API; this adapter has no verbs for them yet. - channel_create = FALSE, leave = FALSE, set_state = FALSE, + channel_create = TRUE, leave = TRUE, set_state = FALSE, files = FALSE, attachments = FALSE, typing = FALSE, e2ee = FALSE, identity_override = TRUE, rich_markup = character(), markup_dialects = c("plain", "markdown"), diff --git a/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index dee62d4..17c3347 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -406,6 +406,59 @@ local({ expect_true(caps$join) }) +# ---- Creating ---- +# conversations.create; Slack's answer names the channel, and that name +# is the return value, since names are what every other verb in this +# adapter takes. Adapter options (is_private) ride into the body. + +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., body = NULL, .method, token) { + seen <<- list(path = path, body = body, .method = .method) + list(ok = TRUE, channel = list(id = "C9", name = "warroom")) + }) + expect_identical(chat_channel_create(cl, "#warroom", is_private = TRUE), + "warroom") + expect_identical(seen$path, "/api/conversations.create") + expect_identical(seen$.method, "POST") + expect_identical(seen$body$name, "warroom") + expect_true(seen$body$is_private) +}) + +# A refusal is an error, not a phantom channel: the caller must not walk +# away with a name it believes is a room. +expect_error(chat_channel_create(slack_api_client(function(...) { + list(ok = FALSE, error = "name_taken") +}), "warroom"), "Slack refused conversations.create: name_taken") + +# ---- Leaving ---- + +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., body = NULL, .method, token) { + seen <<- list(path = path, body = body, .method = .method) + list(ok = TRUE) + }) + expect_identical(chat_leave(cl, "#lab"), "lab") + expect_identical(seen$path, "/api/conversations.leave") + expect_identical(seen$.method, "POST") + expect_identical(seen$body$channel, "lab") +}) + +# A refusal propagates: a leave that quietly failed keeps delivering a +# channel the caller believes it has left. +expect_error(chat_leave(slack_api_client(function(...) { + list(ok = FALSE, error = "not_in_channel") +}), "lab"), "Slack refused conversations.leave: not_in_channel") + +local({ + caps <- chat_capabilities(chat_slack(channels = "lab", token = "t", + .history = function(...) NULL, + .post = function(...) "1")) + expect_true(caps$channel_create) + expect_true(caps$leave) +}) + # ---- Identity ---- slack_msg <- function(body = "", mentions = NULL) { chat_message(id = "1.1", channel = "lab", sender = "U999",