From 4bc5fceeb5fbb242d5ec6ef05d2e5858057e5eb1 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 8 Sep 2026 14:06:50 -0500 Subject: [PATCH 1/3] Route the Telegram adapter through a telegram::TGBot when given chat_telegram() takes a bot = telegram::TGBot from the suggested telegram package (CRAN 0.7.1). Requests then go through the class's public req(method, body), so its proxy settings apply and the token can stay private to the object; attachments are fetched from the URL its getFile() returns when called without a destfile. Only the transport is borrowed. The class's own verbs would degrade the adapter: getUpdates() can neither long-poll nor choose update kinds, parsed_content() flattens updates into data frames, getMe() prints, getFile() answers NULL where the Bot API refused, and there are no edit, reaction, chat or leave methods. The direct httr layer stays the default and now shares its response parsing with the TGBot one. The .download seam takes (file_id, file_path, dest) so both layers fit behind it. Bindings on a TGBot are locked, so behavior is tested on a stand-in with the two members the adapter uses; the real class's req() and getFile() signatures are pinned when telegram is installed. --- DESCRIPTION | 1 + R/telegram.R | 142 ++++++++++++++++++++++++-------- README.md | 2 +- inst/tinytest/test_telegram.R | 150 +++++++++++++++++++++++++++++++++- man/chat_telegram.Rd | 22 +++-- 5 files changed, 274 insertions(+), 43 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index b7ad052..c742b50 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -26,5 +26,6 @@ Suggests: mx.client (>= 0.2.0.8), mx.crypto (>= 0.2.1.1), slackr, + telegram, tinytest Encoding: UTF-8 diff --git a/R/telegram.R b/R/telegram.R index 39203b5..e5bae23 100644 --- a/R/telegram.R +++ b/R/telegram.R @@ -1,6 +1,7 @@ #' @title Telegram adapter #' @description chat.api methods for the Telegram Bot API, with HTTP -#' delegated to the suggested httr package. Receive is getUpdates +#' delegated to the suggested httr package, or to a +#' \code{telegram::TGBot} when one is supplied. Receive is getUpdates #' long polling: one call returns every update Telegram is holding #' for the bot across every chat it is in, so the cursor is a single #' update offset rather than one per channel. Sends, edits, @@ -16,7 +17,7 @@ #' Create a Telegram chat client #' #' Requires the suggested \pkg{httr} package and a bot token from -#' BotFather. +#' BotFather, or a \code{telegram::TGBot} that carries one. #' #' Channels are chat identifiers as Telegram reports them -- a #' positive number for a private chat, a negative one for a group or @@ -34,7 +35,17 @@ #' @param timeout Long-poll wait in seconds, used by #' \code{\link{chat_poll}} when it is given no \code{timeout}. #' @param api_url Base URL of the Bot API. The default is Telegram's; -#' a local Bot API server takes its own. +#' a local Bot API server takes its own. Ignored when \code{bot} is +#' given, since the class fixes the host. +#' @param bot A \code{telegram::TGBot} from the suggested \pkg{telegram} +#' package, or NULL. When given, every request goes through its public +#' \code{req()} method, so its proxy settings apply and \code{token} +#' may be left empty: the object holds its own. The class's verbs are +#' not used, only its transport. Its \code{getUpdates()} can neither +#' long-poll nor choose update kinds, its parser flattens updates into +#' data frames, and it has no edit, reaction, chat or leave methods; +#' \code{req()} is what carries this adapter. Attachments are fetched +#' from the URL its \code{getFile()} returns. #' @param .api Testing seam: replacement for the HTTP layer, a #' \code{function(method, params, files)} returning the parsed #' response (\code{list(ok =, result =)}). \code{params} arrives @@ -42,33 +53,49 @@ #' \code{"true"}/\code{"false"}, numbers as plain digits. Leave NULL #' in production. #' @param .download Testing seam: replacement for the file fetch, a -#' \code{function(file_path, dest)} writing the bytes behind a -#' getFile path to \code{dest}. Leave NULL in production; when both -#' seams are supplied the httr package is not required. +#' \code{function(file_id, file_path, dest)} writing the bytes behind +#' a getFile answer to \code{dest}. Leave NULL in production; when +#' both seams are supplied neither httr nor a bot is required. #' @return A \code{chat_client} of class \code{chat_telegram}. #' @export chat_telegram <- function(token = Sys.getenv("TELEGRAM_BOT_TOKEN"), timeout = 30L, - api_url = "https://api.telegram.org", .api = NULL, - .download = NULL) { + api_url = "https://api.telegram.org", bot = NULL, + .api = NULL, .download = NULL) { + has_bot <- !is.null(bot) + if (has_bot && !is.function(bot$req)) { + stop("chat_telegram(): `bot` must be a telegram::TGBot, or an ", + "object with a req(method, body) method.", call. = FALSE) + } + # httr carries both transports: it is the direct one, and it is what + # unwraps a TGBot's responses -- telegram imports it, so a TGBot never + # arrives without it. if ((is.null(.api) || is.null(.download)) && !requireNamespace("httr", quietly = TRUE)) { stop("chat_telegram() requires the 'httr' package. ", "Install it first.", call. = FALSE) } - if (!is.character(token) || length(token) != 1L || !nzchar(token)) { - stop("chat_telegram() needs a bot token (TELEGRAM_BOT_TOKEN).", - call. = FALSE) + token <- as.character(token %||% "")[[1L]] + if (!has_bot && (is.na(token) || !nzchar(token))) { + stop("chat_telegram() needs a bot token (TELEGRAM_BOT_TOKEN) or ", + "a telegram::TGBot as `bot`.", call. = FALSE) } api_url <- sub("/+$", "", api_url) env <- new.env(parent = emptyenv()) env$cursor <- NULL env$whoami <- NULL structure(list(env = env, token = token, timeout = as.integer(timeout), - api_url = api_url, - api_fn = .api %||% telegram_http(token, api_url), - download_fn = .download %||% - telegram_http_download(token, api_url)), + api_url = api_url, bot = bot, + api_fn = .api %||% if (has_bot) { + telegram_tgbot_api(bot) + } else { + telegram_http(token, api_url) + }, + download_fn = .download %||% if (has_bot) { + telegram_tgbot_download(bot) + } else { + telegram_http_download(token, api_url) + }), class = c("chat_telegram", "chat_client")) } @@ -159,18 +186,41 @@ telegram_http <- function(token, api_url) { } resp <- httr::POST(url, body = body, encode = encode, httr::timeout(wait + 30)) - # Read the body whatever the status: Telegram says no with a - # 4xx that still carries {ok: false, description}, and the - # description is the part worth reporting. - parsed <- tryCatch( - httr::content(resp, as = "parsed", type = "application/json"), - error = function(e) NULL) - if (!is.list(parsed)) { - stop("chat.api: Telegram ", method, " answered HTTP ", - httr::status_code(resp), " with no JSON body.", - call. = FALSE) + telegram_parse(resp, method) + } +} + +# Read the body whatever the status: Telegram says no with a 4xx that +# still carries {ok: false, description}, and the description is the +# part worth reporting. +telegram_parse <- function(resp, method) { + parsed <- tryCatch( + httr::content(resp, as = "parsed", type = "application/json"), + error = function(e) NULL) + if (!is.list(parsed)) { + stop("chat.api: Telegram ", method, " answered HTTP ", + httr::status_code(resp), " with no JSON body.", call. = FALSE) + } + parsed +} + +# The same layer over a telegram::TGBot. Its public req() is a POST of +# a body to /bot/ with the object's proxy applied, and +# it hands back the httr response, which is exactly what the direct +# layer builds for itself. It also runs httr::warn_for_status(), so a +# refused call warns there and then errors in telegram_call() with +# Telegram's description; both are kept, since the first is the +# package's and the second is the one with the reason in it. There is +# no request timeout on that path: a long poll waits as long as curl +# does. +telegram_tgbot_api <- function(bot) { + force(bot) + function(method, params = list(), files = NULL) { + body <- params + if (length(files)) { + body <- c(body, lapply(files, httr::upload_file)) } - parsed + telegram_parse(bot$req(method, body = body), method) } } @@ -179,17 +229,41 @@ telegram_http <- function(token, api_url) { telegram_http_download <- function(token, api_url) { force(token) force(api_url) - function(file_path, dest) { - url <- sprintf("%s/file/bot%s/%s", api_url, token, file_path) - resp <- httr::GET(url, httr::write_disk(dest, overwrite = TRUE)) - if (!identical(httr::status_code(resp), 200L)) { - stop("chat.api: Telegram file fetch answered HTTP ", - httr::status_code(resp), ".", call. = FALSE) + function(file_id, file_path, dest) { + telegram_fetch(sprintf("%s/file/bot%s/%s", api_url, token, file_path), + dest) + } +} + +# Over a TGBot the token is private, so the download URL comes from the +# class's own getFile(), called without a destfile: that is the one +# thing it returns rather than downloads. It answers NULL rather than +# raising for a file the Bot API will not serve, which is turned back +# into the error the direct path raises. The class's own download would +# have gone through curl without its proxy anyway, so nothing is lost +# by fetching the URL here. +telegram_tgbot_download <- function(bot, fetch = telegram_fetch) { + force(bot) + force(fetch) + function(file_id, file_path, dest) { + url <- bot$getFile(file_id) + if (!is.character(url) || length(url) != 1L || !nzchar(url)) { + stop("chat.api: Telegram getFile served no download URL for ", + file_id, ".", call. = FALSE) } - invisible(dest) + fetch(url, dest) } } +telegram_fetch <- function(url, dest) { + resp <- httr::GET(url, httr::write_disk(dest, overwrite = TRUE)) + if (!identical(httr::status_code(resp), 200L)) { + stop("chat.api: Telegram file fetch answered HTTP ", + httr::status_code(resp), ".", call. = FALSE) + } + invisible(dest) +} + # One place for the answer shape. A refused call is {ok: false, # description} and raises with the description; a good one is # unwrapped to its result. A seam that answers with something that is @@ -658,6 +732,6 @@ chat_download.chat_telegram <- function(client, attachment, dest = NULL, ...) { } # Errors propagate, chat_react()'s reasoning: a fetch that quietly # failed leaves the caller pointing at a path with no bytes. - client$download_fn(path, dest) + client$download_fn(attachment$id, path, dest) invisible(dest) } diff --git a/README.md b/README.md index de089fd..6ddeb5e 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ adapters that wake up when their platform client is installed: | Matrix | `chat_matrix()` | mx.client (Suggests) | working | | IRC | `chat_irc()` | base R sockets | working | | Slack | `chat_slack()` | slackr (Suggests) | signature-verified, review-hardened; live roundtrip pending a workspace token | -| Telegram | `chat_telegram()` | httr (Suggests) | every verb seam-tested against Bot API shapes; live roundtrip pending a bot token | +| Telegram | `chat_telegram()` | httr, or a telegram::TGBot (Suggests) | every verb seam-tested against Bot API shapes; live roundtrip pending a bot token | ```r cl <- chat.api::chat_matrix(app = "mybot") diff --git a/inst/tinytest/test_telegram.R b/inst/tinytest/test_telegram.R index 2367e1e..eda794e 100644 --- a/inst/tinytest/test_telegram.R +++ b/inst/tinytest/test_telegram.R @@ -34,7 +34,8 @@ if (requireNamespace("httr", quietly = TRUE)) { # ---- Seams ---- # A client that needs neither httr nor the network. The download seam # defaults to a no-op that reports the destination it was handed. -tg_client <- function(api, download = function(file_path, dest) dest, ...) { +tg_client <- function(api, download = function(file_id, file_path, dest) dest, + ...) { chat_telegram(token = "123:fake", .api = api, .download = download, ...) } @@ -337,14 +338,16 @@ local({ list(ok = TRUE, result = list(file_id = params$file_id, file_path = "photos/file_1.jpg")) })) - cl <- tg_client(s$api, download = function(file_path, dest) { - fetched <<- list(file_path = file_path, dest = dest) + cl <- tg_client(s$api, download = function(file_id, file_path, dest) { + fetched <<- list(file_id = file_id, file_path = file_path, + dest = dest) writeBin(as.raw(1:4), dest) dest }) att <- chat_attachment("big") dest <- chat_download(cl, att) expect_identical(s$calls()[[1L]]$params$file_id, "big") + expect_identical(fetched$file_id, "big") expect_identical(fetched$file_path, "photos/file_1.jpg") expect_identical(fetched$dest, dest) expect_true(grepl("[.]jpg$", dest)) @@ -665,6 +668,147 @@ expect_error(chat_set_identity(tg_client(function(...) { list(ok = FALSE, description = "Too Many Requests: retry after 3600") }), "x"), "Too Many Requests") +# ---- Transport over a telegram::TGBot ---- +# Only the class's transport is borrowed: its public req() posts a body +# to the method URL with the object's proxy applied and hands back the +# httr response, which is what the direct layer builds for itself. Its +# verbs are not used -- getUpdates() cannot long-poll or choose update +# kinds, its parser flattens updates into data frames, and it has no +# edit, reaction, chat or leave methods. + +# Drift detection against the real package. The bindings on a TGBot are +# locked, so behavior is tested on a stand-in below; this pins the two +# members the stand-in imitates. +if (requireNamespace("telegram", quietly = TRUE)) { + b <- telegram::TGBot$new(token = "123:fake") + expect_true(is.function(b$req)) + expect_identical(names(formals(b$req)), c("method", "body")) + expect_true(is.function(b$getFile)) + expect_identical(names(formals(b$getFile)), c("file_id", "destfile")) + # A real TGBot builds a client with no token of its own. + cl <- chat_telegram(token = "", bot = b) + expect_true(inherits(cl, "chat_telegram")) + expect_identical(cl$bot, b) +} + +# What req() hands back, as a seam would build it. +tg_response <- function(json, status = 200L, + type = "application/json") { + structure(list(url = "https://api.telegram.org/bot123:fake/x", + status_code = as.integer(status), + headers = list("content-type" = type), + content = charToRaw(json)), + class = "response") +} + +# A stand-in with the two members the adapter uses, recording calls. +tg_fake_bot <- function(answer, file_url = NULL) { + calls <- list() + list(req = function(method, body = NULL) { + calls[[length(calls) + 1L]] <<- list(method = method, body = body) + if (is.function(answer)) answer(method, body) else answer + }, + getFile = function(file_id, destfile = NULL) { + calls[[length(calls) + 1L]] <<- list(method = "getFile*", + file_id = file_id, + destfile = destfile) + invisible(file_url) + }, + calls = function() calls) +} + +# Something that is not a TGBot is refused at construction, not at the +# first call. +expect_error(chat_telegram(token = "t", bot = list(token = "t")), + "req\\(method, body\\)") +# And no token with no bot is the same error as before. +expect_error(chat_telegram(token = ""), "TELEGRAM_BOT_TOKEN") + +if (requireNamespace("httr", quietly = TRUE)) { + # Wire form reaches req() as its body: strings, NULLs dropped. + local({ + bot <- tg_fake_bot(function(method, body) { + if (identical(method, "getMe")) { + tg_response('{"ok":true,"result":{"id":999,"is_bot":true,"first_name":"Corteza","username":"corteza_bot"}}') + } else { + tg_response('{"ok":true,"result":{"message_id":77}}') + } + }) + cl <- chat_telegram(token = "", bot = bot, + .download = function(...) NULL) + who <- chat_whoami(cl) + expect_identical(who$id, "999") + expect_identical(bot$calls()[[1L]]$method, "getMe") + expect_identical(bot$calls()[[1L]]$body, list()) + expect_identical(chat_send(cl, "5", "hi", notify = FALSE), "77") + sent <- bot$calls()[[2L]] + expect_identical(sent$method, "sendMessage") + expect_identical(sent$body, list(chat_id = "5", + disable_notification = "true", + text = "hi")) + }) + + # A file rides the body as an httr upload, next to the fields. + local({ + f <- tempfile(fileext = ".png") + writeBin(as.raw(1:4), f) + bot <- tg_fake_bot(tg_response('{"ok":true,"result":{"message_id":8}}')) + cl <- chat_telegram(token = "", bot = bot, + .download = function(...) NULL) + expect_identical(chat_send(cl, "5", "", files = f), "8") + body <- bot$calls()[[1L]]$body + expect_identical(body$chat_id, "5") + expect_true(inherits(body$document, "form_file")) + expect_identical(body$document$path, f) + }) + + # A refusal comes back through the response body with Telegram's + # description, whatever req() warned on the way. + local({ + bot <- tg_fake_bot(tg_response( + '{"ok":false,"error_code":401,"description":"Unauthorized"}', + status = 401L)) + cl <- chat_telegram(token = "", bot = bot, + .download = function(...) NULL) + expect_error(chat_whoami(cl), "Telegram refused getMe: Unauthorized") + }) + # A body that is not JSON at all is reported with its status. + local({ + bot <- tg_fake_bot(tg_response("bad gateway", + status = 502L, type = "text/html")) + cl <- chat_telegram(token = "", bot = bot, + .download = function(...) NULL) + expect_error(chat_whoami(cl), "answered HTTP 502 with no JSON body") + }) + + # Downloads: the token is private to the class, so the URL comes from + # its own getFile(), asked without a destfile. The fetch itself is + # seamed here; the URL it is handed is what matters. + local({ + got <- NULL + bot <- tg_fake_bot(NULL, + file_url = "https://api.telegram.org/file/bot123:fake/photos/x.jpg") + dl <- chat.api:::telegram_tgbot_download(bot, fetch = function(url, dest) { + got <<- list(url = url, dest = dest) + dest + }) + d <- tempfile(fileext = ".jpg") + expect_identical(dl("big", "photos/x.jpg", d), d) + expect_identical(bot$calls()[[1L]]$file_id, "big") + expect_null(bot$calls()[[1L]]$destfile) + expect_identical(got$url, + "https://api.telegram.org/file/bot123:fake/photos/x.jpg") + expect_identical(got$dest, d) + }) + # The class answers NULL for a file the Bot API will not serve; that + # is an error here, as on the direct path. + local({ + bot <- tg_fake_bot(NULL, file_url = NULL) + dl <- chat.api:::telegram_tgbot_download(bot, fetch = function(...) stop("not reached")) + expect_error(dl("big", "photos/x.jpg", tempfile()), "served no download URL") + }) +} + # ---- Live, opt-in ---- # A read-only round trip against the real API, only where a token is # set and only at home. getMe is the cheapest call there is. diff --git a/man/chat_telegram.Rd b/man/chat_telegram.Rd index fc5ea62..2c622a4 100644 --- a/man/chat_telegram.Rd +++ b/man/chat_telegram.Rd @@ -7,6 +7,7 @@ chat_telegram( token = Sys.getenv("TELEGRAM_BOT_TOKEN"), timeout = 30L, api_url = "https://api.telegram.org", + bot = NULL, .api = NULL, .download = NULL ) @@ -19,7 +20,18 @@ environment variable.} \code{\link{chat_poll}} when it is given no \code{timeout}.} \item{api_url}{Base URL of the Bot API. The default is Telegram's; -a local Bot API server takes its own.} +a local Bot API server takes its own. Ignored when \code{bot} is +given, since the class fixes the host.} + +\item{bot}{A \code{telegram::TGBot} from the suggested \pkg{telegram} +package, or NULL. When given, every request goes through its public +\code{req()} method, so its proxy settings apply and \code{token} +may be left empty: the object holds its own. The class's verbs are +not used, only its transport. Its \code{getUpdates()} can neither +long-poll nor choose update kinds, its parser flattens updates into +data frames, and it has no edit, reaction, chat or leave methods; +\code{req()} is what carries this adapter. Attachments are fetched +from the URL its \code{getFile()} returns.} \item{.api}{Testing seam: replacement for the HTTP layer, a \code{function(method, params, files)} returning the parsed @@ -29,16 +41,16 @@ already in wire form: NULLs dropped, logicals as in production.} \item{.download}{Testing seam: replacement for the file fetch, a -\code{function(file_path, dest)} writing the bytes behind a -getFile path to \code{dest}. Leave NULL in production; when both -seams are supplied the httr package is not required.} +\code{function(file_id, file_path, dest)} writing the bytes behind +a getFile answer to \code{dest}. Leave NULL in production; when +both seams are supplied neither httr nor a bot is required.} } \value{ A \code{chat_client} of class \code{chat_telegram}. } \description{ Requires the suggested \pkg{httr} package and a bot token from -BotFather. +BotFather, or a \code{telegram::TGBot} that carries one. } \details{ Channels are chat identifiers as Telegram reports them -- a From f1be657e32b13dba7cccd37984eeb25670cf225a Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 8 Sep 2026 14:07:12 -0500 Subject: [PATCH 2/3] Bump version to 0.0.1.29 --- DESCRIPTION | 2 +- NEWS.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index c742b50..b716269 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,7 +1,7 @@ Package: chat.api Type: Package Title: Transport-Agnostic Chat Contract for R Agents -Version: 0.0.1.28 +Version: 0.0.1.29 Date: 2026-09-08 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/NEWS.md b/NEWS.md index a912617..0939bc9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,14 @@ +# chat.api 0.0.1.29 + +* chat_telegram() accepts a telegram::TGBot as `bot`. Requests then go + through the class's public req(), so its proxy settings apply and the + token can stay inside the object; attachments are fetched from the + URL its getFile() returns. Only the transport is borrowed: the class's + own verbs cannot long-poll, choose update kinds, edit, react, or + leave, and its parser flattens updates into data frames. telegram + joins Suggests. The .download testing seam now takes + (file_id, file_path, dest). + # chat.api 0.0.1.28 * Slack gains user-token identity: chat_slack(user_token =) plus From 3eda7f0e56f3de535f66a43510447db2e12e84f5 Mon Sep 17 00:00:00 2001 From: TroyHernandez Date: Tue, 8 Sep 2026 14:11:03 -0500 Subject: [PATCH 3/3] Seam the constructor-error tests so they run without httr CI installs none of the suggested packages, so chat_telegram()'s httr check fires before its token check there and the no-token assertion saw the wrong message. With both seams supplied the constructor gets past httr and the test exercises what it names. --- inst/tinytest/test_telegram.R | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/inst/tinytest/test_telegram.R b/inst/tinytest/test_telegram.R index eda794e..2176870 100644 --- a/inst/tinytest/test_telegram.R +++ b/inst/tinytest/test_telegram.R @@ -718,11 +718,17 @@ tg_fake_bot <- function(answer, file_url = NULL) { } # Something that is not a TGBot is refused at construction, not at the -# first call. -expect_error(chat_telegram(token = "t", bot = list(token = "t")), +# first call. Seams supplied, so this is the bot check and not the +# httr check that would otherwise come first on a box without httr +# (CI has none of the suggested packages). +tg_seams <- list(.api = function(...) NULL, + .download = function(...) NULL) +expect_error(do.call(chat_telegram, c(list(token = "t", bot = list(token = "t")), + tg_seams)), "req\\(method, body\\)") # And no token with no bot is the same error as before. -expect_error(chat_telegram(token = ""), "TELEGRAM_BOT_TOKEN") +expect_error(do.call(chat_telegram, c(list(token = ""), tg_seams)), + "TELEGRAM_BOT_TOKEN") if (requireNamespace("httr", quietly = TRUE)) { # Wire form reaches req() as its body: strings, NULLs dropped.