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
6 changes: 6 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@
^README\.Rmd$
^cran-comments\.md$
^CRAN-SUBMISSION$
^\.Rhistory$
^\.DS_Store$
^\.claude$
^\.github$
^evolution\.Rcheck$
^evolution_.*\.tar\.gz$
52 changes: 52 additions & 0 deletions .github/workflows/R-CMD-check.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
on:
push:
branches: [main, master]
pull_request:
workflow_dispatch:

name: R-CMD-check.yaml

permissions: read-all

jobs:
R-CMD-check:
runs-on: ${{ matrix.config.os }}

name: ${{ matrix.config.os }} (${{ matrix.config.r }})

strategy:
fail-fast: false
matrix:
config:
- {os: macos-latest, r: 'release'}
- {os: windows-latest, r: 'release'}
- {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'}
- {os: ubuntu-latest, r: 'release'}
- {os: ubuntu-latest, r: 'oldrel-1'}

env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
R_KEEP_PKG_SOURCE: yes

steps:
- uses: actions/checkout@v4

- uses: r-lib/actions/setup-pandoc@v2

- uses: r-lib/actions/setup-r@v2
with:
r-version: ${{ matrix.config.r }}
http-user-agent: ${{ matrix.config.http-user-agent }}
use-public-rspm: true

- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::rcmdcheck
needs: check

- uses: r-lib/actions/check-r-package@v2
with:
upload-snapshots: true
build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")'
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@
.RData
.Ruserdata
.DS_Store
.DS_Store
.claude
evolution.Rcheck
evolution_*.tar.gz
4 changes: 2 additions & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
Package: evolution
Type: Package
Title: A Client for 'Evolution Cloud API'
Date: 2026-02-27
Version: 0.1.0
Date: 2026-06-14
Version: 0.1.1
Authors@R: c(
person("Andre", "Leite", email = "leite@castlab.org", role = c("aut", "cre")),
person("Hugo", "Vasconcelos", email = "hugo.vasconcelos@ufpe.br", role = "aut"),
Expand Down
4 changes: 2 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
YEAR: 2025
COPYRIGHT HOLDER: webdav authors
YEAR: 2025-2026
COPYRIGHT HOLDER: Andre Leite, Hugo Vasconcelos, Diogo Bezerra
41 changes: 40 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,43 @@
# evolution (development version)
# evolution 0.1.1

## Bug fixes

* Defined the null-coalescing operator `%||%` internally. It was used by
`.evo_post()` and `send_contact()` but is only part of base R since
4.4.0, so the package failed to load on R 4.2--4.3 (the declared
minimum is R 4.2.0).

* `send_*()` functions now normalise the recipient `number`, stripping
formatting characters (spaces, dashes, parentheses and a leading `+`)
before the request. Previously a number such as `"+5581999990000"` was
sent verbatim and could be rejected by the API, even though it was
shown as valid in the documentation. JIDs (values containing `@`, e.g.
group ids `...@g.us`) are passed through unchanged.

* `.normalize_media()` no longer silently treats a mistyped file path as
base64. It now requires standard (padded) base64 and emits a
path-aware error pointing at the resolved location when a file is not
found.

* Corrected the copyright holder in `LICENSE`, which still referenced an
unrelated package.

## Documentation

* Unified the API reference URL across `README` and `DESCRIPTION`
(`https://evoapicloud.com`).

* Removed a dead "apikey" redaction branch in the verbose logger (the
API key is only ever sent as a header, never in the request body).

## Internal

* Added a `testthat` suite covering the offline helpers (`jid()`,
`.normalize_media()`, `.compact()`, `.evo_path()`,
`.assert_scalar_string()`) and the argument-validation guard clauses of
the exported `send_*()` functions and `evo_client()`.

* Added `.Rhistory` and `.DS_Store` to `.Rbuildignore`.

# evolution 0.1.0

Expand Down
63 changes: 54 additions & 9 deletions R/evolution.R
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ evo_client <- function(base_url, api_key, instance) {

req <- httr2::request(sub("/+$", "", base_url)) |>
httr2::req_headers(apikey = api_key, `Content-Type` = "application/json") |>
httr2::req_user_agent("evolution-r/0.1.0 (httr2)") |>
httr2::req_user_agent("evolution-r/0.1.1 (httr2)") |>
httr2::req_retry(max_tries = 3)

structure(list(req = req, instance = instance), class = "evo_client")
Expand Down Expand Up @@ -105,8 +105,9 @@ print.evo_client <- function(x, ...) {
if (isTRUE(verbose)) {
cli::cli_rule(left = "{.strong evoapi} POST {path}")
cli::cli_alert_info("Timeout: {timeout}s")
# The apikey travels in the request header, never in the body, so there is
# nothing to redact here; only large media payloads need truncating.
show <- body
if (!is.null(show$apikey)) show$apikey <- "<REDACTED>"
if (!is.null(show$media) && nchar(show$media) > 80) {
show$media <- paste0(substr(show$media, 1, 40), "...<truncated>")
}
Expand Down Expand Up @@ -219,7 +220,9 @@ jid <- function(number) {
#'
#' @param client An [evo_client()] object.
#' @param number Character. Recipient number with country code
#' (e.g., `"5581999990000"` or `"+5581999990000"`).
#' (e.g., `"5581999990000"` or `"+5581999990000"`). Formatting characters
#' (spaces, dashes, parentheses and a leading `+`) are stripped before the
#' request; a JID such as a group id `...@@g.us` is passed through unchanged.
#' @param text Character. Message body.
#' @param delay Integer (ms). Optional presence delay before sending.
#' Simulates typing before the message is sent.
Expand Down Expand Up @@ -247,6 +250,7 @@ send_text <- function(client, number, text, delay = NULL,
mentioned = NULL, quoted = NULL, verbose = FALSE) {
.assert_scalar_string(number, "number")
.assert_scalar_string(text, "text")
number <- .normalize_number(number)

body <- list(
number = number,
Expand Down Expand Up @@ -340,6 +344,7 @@ send_media <- function(client, number, mediatype, mimetype,
.assert_scalar_string(number, "number")
.assert_scalar_string(mimetype, "mimetype")
.assert_scalar_string(file_name, "file_name")
number <- .normalize_number(number)
if (!mediatype %in% c("image", "video", "document")) {
cli::cli_abort(
'{.arg mediatype} must be one of {.val image}, {.val video}, or {.val document}. Got {.val {mediatype}}.'
Expand Down Expand Up @@ -383,6 +388,7 @@ send_whatsapp_audio <- function(client, number, audio, delay = NULL,
quoted = NULL, verbose = FALSE) {
.assert_scalar_string(number, "number")
.assert_scalar_string(audio, "audio")
number <- .normalize_number(number)

audio_norm <- .normalize_media(audio)
body <- list(
Expand Down Expand Up @@ -413,6 +419,7 @@ send_whatsapp_audio <- function(client, number, audio, delay = NULL,
send_sticker <- function(client, number, sticker, delay = NULL, verbose = FALSE) {
.assert_scalar_string(number, "number")
.assert_scalar_string(sticker, "sticker")
number <- .normalize_number(number)

sticker_norm <- .normalize_media(sticker)
body <- list(number = number, sticker = sticker_norm, delay = delay)
Expand Down Expand Up @@ -444,6 +451,7 @@ send_location <- function(client, number, latitude, longitude,
if (!is.numeric(latitude) || !is.numeric(longitude)) {
cli::cli_abort("{.arg latitude} and {.arg longitude} must be numeric values.")
}
number <- .normalize_number(number)

body <- list(
number = number,
Expand Down Expand Up @@ -486,6 +494,7 @@ send_location <- function(client, number, latitude, longitude,
#' @export
send_contact <- function(client, number, contact, verbose = FALSE) {
.assert_scalar_string(number, "number")
number <- .normalize_number(number)

to_wuid <- function(num) {
clean <- gsub("[^0-9]", "", num)
Expand Down Expand Up @@ -586,6 +595,7 @@ send_buttons <- function(client, number, title, description, footer, buttons,
.assert_scalar_string(title, "title")
.assert_scalar_string(description, "description")
.assert_scalar_string(footer, "footer")
number <- .normalize_number(number)
if (!is.list(buttons) || length(buttons) == 0L) {
cli::cli_abort("{.arg buttons} must be a non-empty list of button definitions.")
}
Expand Down Expand Up @@ -628,6 +638,7 @@ send_poll <- function(client, number, name, values,
selectable_count = 1L, verbose = FALSE) {
.assert_scalar_string(number, "number")
.assert_scalar_string(name, "name")
number <- .normalize_number(number)
if (!is.character(values) || length(values) < 2L) {
cli::cli_abort("{.arg values} must be a character vector with at least 2 options.")
}
Expand Down Expand Up @@ -697,6 +708,7 @@ send_list <- function(client, number, title, description,
.assert_scalar_string(title, "title")
.assert_scalar_string(description, "description")
.assert_scalar_string(button_text, "button_text")
number <- .normalize_number(number)
if (!is.list(sections) || length(sections) == 0L) {
cli::cli_abort("{.arg sections} must be a non-empty list of section definitions.")
}
Expand Down Expand Up @@ -735,6 +747,7 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) {
if (!is.character(numbers) || length(numbers) == 0L) {
cli::cli_abort("{.arg numbers} must be a non-empty character vector of phone numbers.")
}
numbers <- vapply(numbers, .normalize_number, character(1L), USE.NAMES = FALSE)

body <- list(numbers = as.list(numbers))
.evo_post(client, .evo_path("chat", "whatsappNumbers", client$instance),
Expand All @@ -753,6 +766,24 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) {
}
}

#' Normalise a recipient number for the API
#'
#' @description Strips formatting characters (spaces, dashes, parentheses and
#' the leading `+`) from a plain phone number so the value sent to the API is
#' digits-only. Strings that already look like a JID (containing `@`, e.g. a
#' group id `...@g.us`) are returned unchanged.
#' @keywords internal
#' @param x A single character string (phone number or JID).
#' @return A normalised character scalar.
.normalize_number <- function(x) {
if (grepl("@", x, fixed = TRUE)) return(x)
cleaned <- gsub("[^0-9]", "", x)
if (!nzchar(cleaned)) {
cli::cli_abort("{.arg number} does not contain any digits: {.val {x}}.")
}
cleaned
}

#' Normalise media input (URL, file path, base64, data-URI)
#' @keywords internal
#' @param x The media input to normalise.
Expand Down Expand Up @@ -780,13 +811,27 @@ check_is_whatsapp <- function(client, numbers, verbose = FALSE) {
x <- sub("^data:.*;base64,", "", x)
}

# Clean whitespace and validate as base64
x <- gsub("\\s+", "", x)
if (!grepl("^[A-Za-z0-9+/=]+$", x)) {
# Case 4: raw base64. Standard (padded) base64 uses only [A-Za-z0-9+/=],
# has no `.`, and its length is a multiple of 4. Requiring all three avoids
# silently treating a mistyped file path (e.g. "report.pdf") as base64.
x_clean <- gsub("\\s+", "", x)
is_base64 <- nzchar(x_clean) &&
grepl("^[A-Za-z0-9+/]+={0,2}$", x_clean) &&
nchar(x_clean) %% 4L == 0L
if (is_base64) return(x_clean)

# Otherwise: not a URL, an existing file, a data-URI, or valid base64.
# When the input looks like a path, point at the resolved location to help
# the user spot a typo instead of failing with a vague base64 error.
if (grepl("[.~/\\\\]", x) || grepl("\\s", x)) {
cli::cli_abort(c(
"x" = "{.arg media} does not appear to be a valid URL, file path, or base64 string.",
"i" = "Expected one of: HTTP(S) URL, existing file path, base64 string, or data-URI."
"x" = "{.arg media} looks like a file path, but no file exists there.",
"i" = "Resolved path (with {.code ~} expanded): {.file {path.expand(x)}}.",
"i" = "Pass an existing file path, an HTTP(S) URL, or a base64 string."
))
}
x
cli::cli_abort(c(
"x" = "{.arg media} does not appear to be a valid URL, file path, or base64 string.",
"i" = "Expected one of: HTTP(S) URL, existing file path, base64 string, or data-URI."
))
}
4 changes: 4 additions & 0 deletions R/zzz.R
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Null-coalescing operator. `%||%` is only part of base R since 4.4.0, but the
# package supports R (>= 4.2.0), so we define it internally to stay portable.
`%||%` <- function(x, y) if (is.null(x)) y else x

.onLoad <- function(libname, pkgname) {
if (is.null(getOption("evolution.timeout"))) {
options(evolution.timeout = 60)
Expand Down
9 changes: 5 additions & 4 deletions README.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ knitr::opts_chunk$set(

![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/evolution)&nbsp;
![CRAN&nbsp;Downloads](https://cranlogs.r-pkg.org/badges/grand-total/evolution)&nbsp;
![License](https://img.shields.io/badge/license-MIT-darkviolet.svg)&nbsp;
![](https://img.shields.io/badge/devel%20version-0.1.0-orangered.svg)
![License](https://img.shields.io/badge/license-MIT-darkviolet.svg)&nbsp;
[![R-CMD-check](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml)&nbsp;
![](https://img.shields.io/badge/devel%20version-0.1.1-orangered.svg)

<!-- badges: end -->

> R wrapper for [Evolution API v2](https://doc.evolution-api.com) — a lightweight WhatsApp integration API.
> R wrapper for [Evolution Cloud API](https://evoapicloud.com) — a lightweight WhatsApp integration API.

## Overview

Expand Down Expand Up @@ -200,5 +201,5 @@ Contributions are welcome! Open issues with reproducible examples and sanitised

## License

MIT © 2025 Andre Leite, Hugo Vasconcelos & Diogo Bezerra
MIT © 2025–2026 Andre Leite, Hugo Vasconcelos & Diogo Bezerra
See [LICENSE](LICENSE) for details.
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@
![CRAN_Status_Badge](https://www.r-pkg.org/badges/version/evolution) 
![CRAN Downloads](https://cranlogs.r-pkg.org/badges/grand-total/evolution) 
![License](https://img.shields.io/badge/license-MIT-darkviolet.svg) 
![](https://img.shields.io/badge/devel%20version-0.1.0-orangered.svg)
[![R-CMD-check](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml/badge.svg)](https://github.com/StrategicProjects/evolution/actions/workflows/R-CMD-check.yaml)
![](https://img.shields.io/badge/devel%20version-0.1.1-orangered.svg)

<!-- badges: end -->

> R wrapper for [Evolution API v2](https://doc.evolution-api.com) — a
> R wrapper for [Evolution Cloud API](https://evoapicloud.com) — a
> lightweight WhatsApp integration API.

## Overview
Expand Down Expand Up @@ -204,5 +205,5 @@ sanitised logs (remove API keys and phone numbers).

## License

MIT © 2025 Andre Leite, Hugo Vasconcelos & Diogo Bezerra See
MIT © 2025–2026 Andre Leite, Hugo Vasconcelos & Diogo Bezerra See
[LICENSE](LICENSE) for details.
Loading
Loading