Skip to content

Commit

Permalink
Basic Functionality for Daily Archives (#1)
Browse files Browse the repository at this point in the history
* Basic functionality for archiving conversations.

* Users functionality.

* Specify response parser.

Archive users.
  • Loading branch information
jonthegeek authored Dec 16, 2024
1 parent 470f06d commit c0da4e6
Show file tree
Hide file tree
Showing 31 changed files with 1,524 additions and 0 deletions.
1 change: 1 addition & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
^_pkgdown\.yml$
^docs$
^pkgdown$
^data-raw$
6 changes: 6 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ Encoding: UTF-8
Language: en-US
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.2
Imports:
curl,
httr2,
nectar,
rlang,
tibblify
21 changes: 21 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -1,2 +1,23 @@
# Generated by roxygen2: do not edit by hand

S3method(as_slack_ts,POSIXct)
S3method(as_slack_ts,character)
S3method(as_slack_ts,default)
S3method(as_slack_ts,numeric)
export(slack_call_api)
export(slack_conversations_history)
export(slack_conversations_list)
export(slack_conversations_replies)
export(slack_users_info)
export(slack_users_list)
importFrom(rlang,"%||%")
importFrom(rlang,.data)
importFrom(tibblify,tib_chr)
importFrom(tibblify,tib_dbl)
importFrom(tibblify,tib_df)
importFrom(tibblify,tib_int)
importFrom(tibblify,tib_lgl)
importFrom(tibblify,tib_row)
importFrom(tibblify,tib_unspecified)
importFrom(tibblify,tib_variant)
importFrom(tibblify,tspec_df)
105 changes: 105 additions & 0 deletions R/010-call.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Set up the basic call once at package build.
slack_req_base <- nectar::req_setup(
"https://slack.com/api",
user_agent = "slackapi (https://github.com/jonthegeek/slackapi)"
)

#' Call the Slack Web API
#'
#' Generate and perform request to a Slack Web API method.
#'
#' @inheritParams nectar::req_modify
#' @inheritParams .slack_req_perform
#' @inheritParams rlang::args_error_context
#' @param response_parser (`function`) A function to parse the server response.
#' Defaults to [slack_response_parser()]. Set this to `NULL` to return the raw
#' response from [httr2::req_perform()].
#' @param token (`character`) A bearer token provided by Slack. A later
#' enhancement will add the ability to generate this token. Slack token are
#' long-lasting, and should be carefully guarded.
#'
#' @return A tibble with the results of the API call.
#' @export
slack_call_api <- function(path,
query = list(),
body = NULL,
method = NULL,
pagination = c("none", "cursor"),
max_results = Inf,
max_reqs = Inf,
response_parser = slack_response_parser,
token = Sys.getenv("SLACK_API_TOKEN"),
call = rlang::caller_env()) {
# Don't pass token in query or body if provided as parameters; we'll instead
# pass it in the header.
token <- token %||% body$token %||% query$token
if (length(body)) {
body$token <- NULL
}
if (length(query)) {
query$token <- NULL
}

req <- nectar::req_modify(
slack_req_base,
path = path,
query = query,
body = body,
method = method
)
req <- .slack_req_auth(req, token = token)

resps <- .slack_req_perform(
req,
pagination = pagination,
max_results = max_results,
max_reqs = max_reqs,
call = call
)

nectar::resp_parse(resps, response_parser = response_parser)
}

#' Choose and apply pagination strategy
#'
#' @inheritParams rlang::args_error_context
#' @inheritParams nectar::req_perform_opinionated
#' @param req (`httr2_request`) The request object to modify.
#' @param pagination (`character`) The pagination scheme to use. Currently either
#' "none" (the default) or "cursor" (a scheme that uses `cursor`-based
#' pagination; see [Pagination through
#' collections](https://api.slack.com/apis/pagination) in the Slack API
#' documentation. We do not currently support "Classic pagination".
#' @param max_results (`integer` or `Inf`) The maximum number of results to
#' return. Note that slightly more results may be returned if `max_results` is
#' not evenly divisible by 100.
#'
#' @inherit nectar::req_perform_opinionated return
#' @keywords internal
.slack_req_perform <- function(req,
pagination,
max_results,
max_reqs,
call) {
next_req <- NULL
if (max_reqs > 1) {
next_req <- .choose_pagination_fn(pagination, call = call)
if (!is.null(next_req)) {
# Use Slack's recommended limit when paginating.
per_page <- 200L
max_reqs <- min(max_reqs, ceiling(max_results / per_page))
req <- httr2::req_url_query(req, limit = per_page)
}
}

# nectar respects it if we put our own retry mechanism on. Slack has retry
# tiers, so it MIGHT make sense to implement those specifically. Dunno if
# Slack sends back info around that, need to check; it seems to always wait 10
# seconds.

nectar::req_perform_opinionated(
req,
next_req = next_req,
max_reqs = max_reqs
)
}
6 changes: 6 additions & 0 deletions R/020-auth.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.slack_req_auth <- function(req, token = NULL) {
if (!is.null(token)) {
req <- httr2::req_auth_bearer_token(req, token)
}
return(req)
}
47 changes: 47 additions & 0 deletions R/030-responses.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#' Basic stop-gap parsing
#'
#' @inheritParams httr2::resp_body_json
#'
#' @return A tibble of results.
#' @export
slack_response_parser <- function(resp) {
# This will only work for a few things right now. We need more complete
# parsing.
#
# I intentionally left the targeting of specific parsers in here for other
# endpoints that return those same types of objects.
results <- httr2::resp_body_json(resp)
if (length(results)) {
if ("messages" %in% names(results)) {
return(
# TODO: If I (even ~naively) specify the spec, I think I can silence the
# messages about unspecified.
tibblify::tibblify(
results[["messages"]], unspecified = "list"
)
)
}
if ("channels" %in% names(results)) {
return(.parse_channel(results$channels))
}
if ("user" %in% names(results)) {
obj <- results[["user"]]
profile <- obj$profile
obj$profile <- NULL
profile$real_name <- NULL
to_return <- tibble::as_tibble(obj) |>
dplyr::mutate(
profile = list(profile)
) |>
tidyr::unnest_wider(profile)
return(to_return)
}
if ("members" %in% names(results)) {
return(.parse_members(results$members))
}
}
cli::cli_abort(c(
"Don't know how to parse this response.",
i = "Response pieces: {names(results)}"
))
}
28 changes: 28 additions & 0 deletions R/040-pagination.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.validate_pagination <- function(pagination, call = rlang::caller_env()) {
rlang::arg_match0(
pagination,
c("none", "cursor"),
error_call = call
)
}

.iterator_fn_cursor <- function() {
httr2::iterate_with_cursor(
"cursor",
resp_param_value = function(resp) {
cursor <- httr2::resp_body_json(resp)$response_metadata$next_cursor
if (!length(cursor) || cursor == "") {
cursor <- NULL
}
return(cursor)
}
)
}

.choose_pagination_fn <- function(pagination, call = rlang::caller_env()) {
pagination <- .validate_pagination(pagination, call)
switch(pagination,
cursor = .iterator_fn_cursor(),
none = NULL
)
}
42 changes: 42 additions & 0 deletions R/050-validation.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
as_slack_ts <- function(x,
arg = rlang::caller_arg(x),
call = rlang::caller_env()) {
UseMethod("as_slack_ts")
}

#' @export
as_slack_ts.POSIXct <- function(x,
arg = rlang::caller_arg(x),
call = rlang::caller_env()) {
as_slack_ts(as.numeric(x), arg = arg, call = call)
}

#' @export
as_slack_ts.numeric <- function(x,
arg = rlang::caller_arg(x),
call = rlang::caller_env()) {
stbl::stabilize_chr_scalar(x, allow_na = FALSE, x_arg = arg, call = call)
}

#' @export
as_slack_ts.character <- function(x,
arg = rlang::caller_arg(x),
call = rlang::caller_env()) {
# TODO: Ideally this should detect whether this is datetime-y or double-y. It
# should also probably use a stbl::to_dbl() function that doesn't exist yet,
# to give better errors about NA.
as_slack_ts(as.double(x), arg = arg, call = call)
}

#' @export
as_slack_ts.default <- function(x,
arg = rlang::caller_arg(x),
call = rlang::caller_env()) {
cli::cli_abort(
c(
"Cannot convert object to Slack timestamp",
i = "{.arg {arg}} is {.obj_type_friendly {x}}."
),
call = call
)
}
43 changes: 43 additions & 0 deletions R/conversations-history.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#' Get conversation history
#'
#' Fetches a conversation's history of messages and events.
#'
#' @inheritParams slack_call_api
#' @param channel (`character`) Conversation ID to fetch history for.
#' @param latest (`datetime` or `double`) End of time range of messages to
#' include in results.
#' @param oldest (`datetime` or `double`) Start of time range of messages to
#' include in results.
#' @param inclusive (`logical`) Include messages with `latest` or `oldest`
#' timestamp in results only when either timestamp is specified.
#' @param include_all_metadata (`logical`) Return all metadata associated with
#' this message.
#'
#' @return A channel's messages as a tibble.
#' @export
slack_conversations_history <- function(channel,
latest = lubridate::now(),
oldest = 0,
inclusive = TRUE,
include_all_metadata = FALSE,
max_results = Inf,
max_reqs = Inf,
token = Sys.getenv("SLACK_API_TOKEN")) {
latest <- as_slack_ts(latest)
oldest <- as_slack_ts(oldest)
slack_call_api(
path = "/conversations.history",
method = "get",
token = token,
query = list(
channel = channel,
latest = latest,
oldest = oldest,
inclusive = inclusive,
include_all_metadata = include_all_metadata
),
pagination = "cursor",
max_results = max_results,
max_reqs = max_reqs
)
}
44 changes: 44 additions & 0 deletions R/conversations-list.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#' Get conversations list
#'
#' Lists all channels in a Slack team.
#'
#' @inheritParams slack_call_api
#' @param team_id (`character`) Encoded team id to list channels in, required if
#' token belongs to org-wide app.
#' @param exclude_archived (`logical`) Set to `TRUE` to exclude archived
#' channels from the list.
#' @param types (`character`) Mix and match channel types by
#' providing a vector of any combination of `public_channel`,
#' `private_channel`, `mpim`, `im`.
#'
#' @return A thread of messages posted to a conversation as a tibble. Note: The
#' parent message is always included in the response.
#' @export
slack_conversations_list <- function(team_id = NULL,
exclude_archived = FALSE,
types = c(
"public_channel",
"private_channel",
"mpim",
"im"
),
max_results = Inf,
max_reqs = Inf,
token = Sys.getenv("SLACK_API_TOKEN")) {
types <- rlang::arg_match(types, multiple = TRUE)
slack_call_api(
path = "/conversations.list",
method = "get",
token = token,
query = list(
team_id = team_id,
exclude_archived = exclude_archived,
types = types,
.multi = "comma"
),
pagination = "cursor",
response_parser = .parse_channel,
max_results = max_results,
max_reqs = max_reqs
)
}
30 changes: 30 additions & 0 deletions R/conversations-members.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#' Get conversations members
#'
#' Retrieve members of a conversation.
#'
#' @inheritParams slack_call_api
#' @param team_id (`character`) Encoded team id to list channels in, required if
#' token belongs to org-wide app.
#' @param exclude_archived (`logical`) Set to `TRUE` to exclude archived
#' channels from the list.
#' @param types (`character`) Mix and match channel types by
#' providing a vector of any combination of `public_channel`,
#' `private_channel`, `mpim`, `im`.
#'
#' @return A thread of messages posted to a conversation as a tibble. Note: The
#' parent message is always included in the response.
#' @export
slack_conversations_members <- function(channel,
max_results = Inf,
max_reqs = Inf,
token = Sys.getenv("SLACK_API_TOKEN")) {
slack_call_api(
path = "/conversations.members",
method = "get",
token = token,
query = list(channel = channel),
pagination = "cursor",
max_results = max_results,
max_reqs = max_reqs
)
}
Loading

0 comments on commit c0da4e6

Please sign in to comment.