diff --git a/DESCRIPTION b/DESCRIPTION index b7ad052..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"), @@ -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/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 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..2176870 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,153 @@ 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. 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(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. + 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