diff --git a/DESCRIPTION b/DESCRIPTION index 1288335..6410032 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.14 +Version: 0.0.1.15 Date: 2026-08-05 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), diff --git a/DESIGN.md b/DESIGN.md index 9d39032..f34c27a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -83,3 +83,175 @@ Capabilities: `chat_capabilities(client)` -> named logical/character: in corteza) against chat_poll/chat_send. PR #155 lands here as the identity/decoration option. 4. Slack adapter (Suggests slackr), then IRC (base R), then Telegram. + +--- + +# The two contracts still missing + +Written 2026-08-07, after phases 1a-1e landed. corteza's `chat_poll()$raw` +is unread and its Matrix message plane is entirely on the contract. What +remains is 15 direct `mx.*` calls, and they are not stragglers of the +same job — they are two jobs the contract never had a shape for. + +| what | calls | verb it needs | +|---|---:|---| +| current state (rooms, history, pending invites) | 3 `mx.api` | below, "state" | +| read receipts | 1 `mx.api` | `chat_mark_read()` | +| credentials and session lifecycle | 11 `mx.client` | below, "credentials" | + +## Contract A: reading state, not deltas + +`chat_poll()` answers "what changed since my cursor". Every remaining +`mx.api` call in corteza asks the opposite question — "what is true +now" — and there is no half of the contract that answers it. + +The three sites: + +- `mx_rooms(sess)` — which rooms am I in? Startup backfill iterates them. +- `mx_messages(sess, rid, dir = "b", limit = 30)` — the recent tail of a + room, replayed into a fresh process's session history so a restart + does not lose context. +- `mx_sync(sess, timeout = 0)` with no `since` — a full sync at startup, + purely to see invitations. Conduit only reports invites that arrived + after the `since` token, so a bot invited while it was down never + learns about it from the poll loop. corteza then hands the raw + response to `mx_extract_invite_records()`. + +That third one is the tell. An invitation is standing state, which the +contract already conceded when `chat_invite` was given no `ts` ("an +invitation is a standing state rather than an event at a moment"). A +record with no timestamp cannot be a delta, so delivering it only +through a cursor-driven poll was always going to leave a hole — and the +hole is exactly the case where the bot was offline when it mattered. + +Proposed surface: + +```r +chat_channels(client) # -> character +chat_history(client, channel, limit = 50, before = NULL) # -> list(chat_message) +chat_pending(client) # -> list(invites = list(chat_invite)) +chat_mark_read(client, channel, message_id) # -> TRUE, invisibly +``` + +Four decisions worth making deliberately: + +1. **`chat_history()` returns chronological order**, oldest first, whatever + the platform's native direction. Matrix `dir = "b"` and Slack + `conversations.history` both hand back newest-first, and corteza + already flips it (`chunk <- rev(msgs$chunk)`). One flip in the adapter + beats one per consumer, and a consumer replaying history into a + transcript needs the order to be right, not documented. +2. **History and poll overlap, and the contract says so.** The same + message can arrive from both, so `chat_message$id` is the dedup key + and adapters must return the same id from both paths. corteza already + depends on this through `seen_event_ids`; today it is a coincidence of + both paths reading `event_id`, not a promise. +3. **`chat_pending()` rather than a `since = NA` mode on `chat_poll()`.** + Overloading the cursor makes "start from nothing" and "tell me what is + standing" the same call, and they differ: the first consumes a cursor + position, the second must not. A bot that asked for pending invites and + thereby reset its read position would replay every room on startup. +4. **`chat_mark_read()` is write-only for now.** Reading other people's + read state is a much larger surface (per-user receipts, per-device, + Slack's absence of it) and nothing needs it. Ship the half with a + consumer. + +Capability flags: `channels`, `history`, `pending`, `mark_read`. Slack has +all four (`conversations.list`, `conversations.history`, +`conversations.mark`; `pending` is empty since Slack bots are added +rather than invited, which `invites = FALSE` already says). IRC has +`channels` and nothing else. Loopback gets `history` for free from its +in-memory log. + +## Contract B: credentials and session lifecycle + +The larger of the two, and a prerequisite for 1f rather than a follow-on. + +### Why 1f is blocked + +Phase 1f is "stop returning `$client` from `chat_poll()`". It cannot be +done yet, and the reason is specific: **corteza rotates its own access +token mid-loop, and disk is how that rotation propagates.** + +A `/model` command renames the bot. The rename calls +`mx.client::mx_set_displayname()`, which wraps itself in +`mx_with_relogin()`, which persists a refreshed config and returns only +`TRUE` — discarding the client it just refreshed. So corteza re-reads +the config from disk (`R/matrix.R:974`, with a comment explaining that +disk is authoritative either way) and rebuilds every client from it. +That is what `chat_now()` and `matrix_reply_send(cfg, ...)` are for, and +there is a regression test driving the whole poll loop to prove the +rotated token reaches the acknowledgement send. + +So the nine `chat_now()` / `matrix_reply_send(cfg, ...)` call sites are +not redundancy to be tidied away — collapsing them onto one long-lived +`chat` object would reintroduce exactly the bug that test guards. And +`chat_poll()$client` is how the loop seeds `cfg` in the first place. +Remove it without moving the rename, and corteza either loses the +rotation or reaches into `chat$env` for it. + +Two rotation paths, two owners, one shared file. That is the actual +defect, and `$client` is a symptom. + +### The eleven calls + +| group | calls | +|---|---| +| where the file lives | `mx_client_config_path`, `mx_client_legacy_config_path` | +| read/write | `mx_client_load`, `mx_client_save`, `mx_client_from_config` | +| credentials to live client | `mx_client_session` | +| refresh | `mx_client_relogin` | +| first-time setup | `mx_client_configure` | +| profile | `mx_set_displayname` | +| msgtype escape hatch | `mx_send_text` | + +The last is not a credential concern: `matrix_send()` falls back to a +direct send for msgtypes the contract's `kind` vocabulary cannot carry +(`m.image` and friends). It goes away by widening `kind`, or stays as an +honest documented hatch. Either way it is separate. + +### Direction + +**Make the client object the sole owner of its credentials**, so rotation +never needs a file to propagate. + +1. **`chat_set_identity(client, display = NULL, avatar = NULL)`.** Moves + the rename behind the contract. The relogin it triggers then happens + inside the adapter and lands in `client$env$mx` like the poll's does. + This single verb is what unblocks 1f: with it, nothing outside the + adapter rotates a token, so `chat_now()` collapses to `chat`, + `matrix_reply_send()` takes the client, and `$client` has no consumer + left. Slack maps it to `users.profile.set`; IRC to `NICK`; + `identity_override = TRUE` adapters already do the per-message form, + and this is the persistent one. +2. **`chat_credentials()` / `chat_save()` — resist.** Where an + application keeps its config is the application's business, and a + contract that owns the file has to own its format, its migration, and + its permissions. What corteza actually needs is narrower: to stop + holding a plain list it indexes by `user_id`, `token`, `room_id`. + Those are Matrix field names, and `chat_whoami()` already replaced the + first. The rest are read at exactly two kinds of site: building a + client (`chat_matrix(mx = cfg)`) and reading policy (`bots`, + `operators`, `model_badge` — corteza's own fields, not Matrix's). + Splitting corteza's config into "transport credentials, opaque" and + "corteza policy, ours" is a corteza change that needs no new verb. +3. **`chat_connect(config)` as the one construction path.** DESIGN.md + sketched it and it was never built; adapters are constructed by + name (`chat_matrix()`, `chat_slack()`). That is fine for an + application that knows its transport, and corteza does. The thing + worth adding is not a generic constructor but a guarantee: **a + `chat_client` is durable and self-healing.** Build it once per + process, hold it, and every verb keeps it current. Document that, + and `chat_now()` stops looking prudent and starts looking like a + workaround. +4. **`mx_client_configure()` stays in corteza.** Interactive first-run + setup is a terminal UI over transport-specific questions (homeserver, + password, device name). A generic version would be a lowest common + denominator that suits nobody. + +### Order + +`chat_set_identity()` first — it is one verb, it unblocks 1f, and 1f is +the last item of phase 1. The config split is a corteza-side change with +no contract dependency and can go in parallel. Contract A is independent +of both. diff --git a/NAMESPACE b/NAMESPACE index 20a12e9..ce48683 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,8 +1,10 @@ # tinyrox says don't edit this manually, but it can't stop you! +export(chat_addressed) export(chat_capabilities) export(chat_channel_info) export(chat_disconnect) +export(chat_identity) export(chat_invite) export(chat_irc) export(chat_join) @@ -17,7 +19,12 @@ export(chat_resolve) export(chat_send) export(chat_slack) export(chat_typing) +export(chat_whoami) +S3method(chat_addressed,chat_irc) +S3method(chat_addressed,chat_matrix) +S3method(chat_addressed,chat_slack) +S3method(chat_addressed,default) S3method(chat_capabilities,chat_irc) S3method(chat_capabilities,chat_loopback) S3method(chat_capabilities,chat_matrix) @@ -50,6 +57,12 @@ S3method(chat_send,chat_matrix) S3method(chat_send,chat_slack) S3method(chat_typing,chat_matrix) S3method(chat_typing,default) +S3method(chat_whoami,chat_irc) +S3method(chat_whoami,chat_loopback) +S3method(chat_whoami,chat_matrix) +S3method(chat_whoami,chat_slack) +S3method(chat_whoami,default) +S3method(print,chat_identity) S3method(print,chat_invite) S3method(print,chat_message) S3method(print,chat_reaction) diff --git a/R/contract.R b/R/contract.R index 6148792..c18b85f 100644 --- a/R/contract.R +++ b/R/contract.R @@ -97,7 +97,9 @@ chat_resolve <- function(client, name, ...) { #' (\code{\link{chat_channel_info}} works), \code{members} #' (\code{\link{chat_members}} works), \code{invites} (invitations come #' back out of \code{\link{chat_poll}}), \code{join} -#' (\code{\link{chat_join}} works), \code{files}, \code{typing}, +#' (\code{\link{chat_join}} works), \code{whoami} +#' (\code{\link{chat_whoami}} works, and with it the default +#' \code{\link{chat_addressed}}), \code{files}, \code{typing}, #' \code{e2ee}, \code{identity_override} (logicals), #' \code{markup_dialects} (character), \code{max_message_bytes} #' (integer or NA). @@ -407,3 +409,131 @@ print.chat_message <- function(x, ...) { x$channel, substr(x$body, 1L, 60L))) invisible(x) } + +#' Who is this client logged in as? +#' +#' The client's own account, as the transport identifies it. +#' +#' The default method throws rather than guessing. Every use of an +#' identity is a comparison -- is this message mine, did someone address +#' me -- and a wrong answer to either is silent: the bot answers itself +#' in a loop, or never answers anyone. An adapter that cannot say who it +#' is should say so. +#' +#' @param client A \code{chat_client}. +#' @param ... Adapter-specific options. +#' @return A \code{\link{chat_identity}}. +#' @examples +#' chat_whoami(chat_loopback()) +#' @export +chat_whoami <- function(client, ...) { + UseMethod("chat_whoami") +} + +#' @export +chat_whoami.default <- function(client, ...) { + stop("chat_whoami() is not supported by this adapter (", + paste(class(client), collapse = "/"), ").", call. = FALSE) +} + +#' Construct an identity record +#' +#' @param id The account identifier, in whatever form the transport +#' uses. Comparable against \code{\link{chat_message}}'s \code{sender} +#' and against the entries of its \code{mentions}: that comparability +#' is the point of the field, so an adapter whose two sides disagree +#' has a bug here rather than a choice. +#' @param display Human-readable name, or NA when the adapter would have +#' to ask the server for it. Never used for matching -- it is not +#' unique, and on most transports any account can set it to any other +#' account's. For logs and prompts only. +#' @param raw The adapter's platform-native identity payload. +#' @return A list with class \code{chat_identity}. +#' @examples +#' chat_identity("@bot:example.org", display = "corteza") +#' @export +chat_identity <- function(id, display = NA_character_, raw = NULL) { + stopifnot(is.character(id), length(id) == 1L, nzchar(id)) + structure(list(id = id, + display = if (is.null(display)) NA_character_ else display, + raw = raw), + class = "chat_identity") +} + +#' @export +print.chat_identity <- function(x, ...) { + cat(sprintf("%s%s\n", x$id, + if (is.na(x$display)) "" else sprintf(" (%s)", x$display))) + invisible(x) +} + +#' Does a message address this client? +#' +#' Answers the question a bot in a room full of people has to ask before +#' replying. Two signals feed it, and which ones exist is the adapter's +#' business rather than the caller's: +#' +#' \enumerate{ +#' \item The message's declared mentions -- Matrix \code{m.mentions}, +#' a Slack user ref. Structured, unambiguous, and the only signal the +#' default method reads. +#' \item The plain-text conventions of the transport. Matrix has +#' \code{@@bot} and the full user id, Slack has \code{<@U0123>}, IRC +#' has a leading \code{nick:}. These are the reason this is a verb +#' and not a field: writing the Matrix form into a consumer is how +#' that consumer ends up knowing it is talking to Matrix. +#' } +#' +#' The default reads only the declared mentions, so an adapter that does +#' not override it under-reports rather than over-reports. A bot that +#' misses being addressed stays quiet; one that thinks it was addressed +#' when it was not talks over people, and unprompted is worse than +#' absent. +#' +#' @param client A \code{chat_client}. +#' @param message A \code{\link{chat_message}}. +#' @param ... Adapter-specific options. +#' @return \code{TRUE} or \code{FALSE}. +#' @examples +#' cl <- chat_loopback() +#' chat_send(cl, "general", "hi") +#' chat_addressed(cl, chat_poll(cl)$messages[[1L]]) +#' @export +chat_addressed <- function(client, message, ...) { + UseMethod("chat_addressed") +} + +#' @export +chat_addressed.default <- function(client, message, ...) { + identity_mentioned(chat_whoami(client)$id, message) +} + +#' Is this id among a message's declared mentions? +#' +#' The transport-neutral half of \code{\link{chat_addressed}}, shared by +#' every method so that an adapter overriding the verb adds its +#' plain-text conventions rather than reimplementing this and getting it +#' subtly different. +#' @noRd +identity_mentioned <- function(id, message) { + # Forced before the short circuit below can skip it. The default + # method passes chat_whoami(client)$id straight in, and && stops at + # an empty mentions list without ever evaluating the promise -- so an + # adapter that cannot say who it is quietly answered FALSE to "were + # you addressed" instead of raising. Silence is what that bug looks + # like from outside, which is also what working looks like. + force(id) + mentions <- unlist(message$mentions, use.names = FALSE) + length(mentions) > 0L && id %in% mentions +} + +#' Escape a literal string for use inside a regular expression +#' +#' Identifiers carry regex metacharacters: a Matrix localpart may contain +#' \code{.}, \code{+} and \code{/}, all legal per the spec. Interpolating +#' one unescaped turns \code{@@a.bot} into a pattern that also matches +#' \code{@@axbot}. +#' @noRd +escape_rx <- function(x) { + gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", x) +} diff --git a/R/irc.R b/R/irc.R index a03c3ee..f580fed 100644 --- a/R/irc.R +++ b/R/irc.R @@ -125,9 +125,43 @@ chat_resolve.chat_irc <- function(client, name, ...) { chat_capabilities.chat_irc <- function(client, ...) { list(threads = FALSE, thread_replies = FALSE, edits = FALSE, reactions = FALSE, reaction_events = FALSE, channel_info = FALSE, - members = FALSE, invites = FALSE, join = FALSE, files = FALSE, - typing = FALSE, e2ee = FALSE, identity_override = FALSE, - markup_dialects = "plain", max_message_bytes = 400L) + members = FALSE, invites = FALSE, join = FALSE, whoami = TRUE, + files = FALSE, typing = FALSE, e2ee = FALSE, + identity_override = FALSE, markup_dialects = "plain", + max_message_bytes = 400L) +} + +#' @export +chat_whoami.chat_irc <- function(client, ...) { + # The nick this client sent in its NICK line. A server that refused + # it and assigned another (collision, or a nick longer than the + # server allows) has not been read back here -- the 001 welcome + # carries the real one, and this adapter does not parse it yet. + chat_identity(client$nick) +} + +# What may sit next to a nick without being part of it. IRC nicks are +# letters, digits, and "[]\\`_^{|}-", so a plain \\w boundary would let +# "bot" match inside "bot|away", which is the same person's other client +# but not the nick that was addressed. +IRC_NICK_CHARS <- "A-Za-z0-9\\[\\]\\\\`_^{|}-" + +#' @export +chat_addressed.chat_irc <- function(client, message, ...) { + nick <- chat_whoami(client)$id + if (identity_mentioned(nick, message)) { + return(TRUE) + } + body <- message$body %||% "" + if (!nzchar(body)) { + return(FALSE) + } + # IRC has no mention markup at all: addressing someone is a naming + # convention ("bot: do the thing"), so the bare nick as a whole word + # is the entire signal available. + grepl(sprintf("(? "bot". "" for anything not shaped like a user id, +# which the caller must read as "no localpart to match on" rather than +# building an empty pattern that matches everywhere. +matrix_localpart <- function(id) { + if (!grepl("^@[^:]+:", id)) { + return("") + } + sub(":.*$", "", sub("^@", "", id)) +} + +# The characters a Matrix localpart may contain, per the identifier +# grammar. Used as a negative lookahead so "@bot" does not match inside +# "@bot2" or "@bot.deploy", both of which are somebody else. +MATRIX_LOCALPART_CHARS <- "a-z0-9._=/+-" + +#' @export +chat_addressed.chat_matrix <- function(client, message, ...) { + id <- chat_whoami(client)$id + if (identity_mentioned(id, message)) { + return(TRUE) + } + body <- message$body %||% "" + if (!nzchar(body)) { + return(FALSE) + } + # The full user id, spelled out. A rich mention renders as a pill + # whose plain-text fallback is usually the display name, so this is + # for the person who typed the id instead of clicking a completion. + if (grepl(id, body, fixed = TRUE)) { + return(TRUE) + } + localpart <- matrix_localpart(id) + if (!nzchar(localpart)) { + return(FALSE) + } + # "@bot" as a whole word. The lookahead is what \\b would be if \\b + # knew about Matrix: a localpart may contain "." and "-", so \\b ends + # the word early and "@bot.deploy" matches "@bot" -- the wrong bot + # answers, and the right one never sees it. + grepl(sprintf("@%s(?![%s])", escape_rx(localpart), MATRIX_LOCALPART_CHARS), + body, perl = TRUE, ignore.case = TRUE) +} + `%||%` <- function(a, b) { if (is.null(a)) { b diff --git a/R/slack.R b/R/slack.R index 66370f8..c6ff06b 100644 --- a/R/slack.R +++ b/R/slack.R @@ -383,8 +383,48 @@ chat_capabilities.chat_slack <- function(client, ...) { # conversations.history says nothing about it -- there is no # invitation to hand a consumer. Joining an open channel is a # different thing, and does work. - invites = FALSE, join = TRUE, + invites = FALSE, join = TRUE, whoami = TRUE, files = FALSE, typing = FALSE, e2ee = FALSE, identity_override = TRUE, markup_dialects = c("plain", "markdown"), max_message_bytes = 40000L) } + +#' @export +chat_whoami.chat_slack <- function(client, ...) { + # Cached for the client's lifetime. Unlike Matrix, where the id is + # already a field of the config in hand, Slack only answers this over + # the network -- and chat_addressed() asks once per message. The + # answer is a property of the token, which cannot change underneath a + # client that was constructed with it. + if (!is.null(client$env$whoami)) { + return(client$env$whoami) + } + api <- client$api_fn %||% slackr::call_slack_api + body <- slack_body(api("/api/auth.test", .method = "GET", + token = client$token)) + slack_stop_for_error(body, "auth.test") + id <- body$user_id + if (is.null(id) || !length(id) || !nzchar(as.character(id)[[1L]])) { + stop("chat.api: Slack auth.test returned no user_id.", call. = FALSE) + } + who <- chat_identity(as.character(id)[[1L]], + display = body$user %||% NA_character_, + raw = body) + client$env$whoami <- who + who +} + +#' @export +chat_addressed.chat_slack <- function(client, message, ...) { + id <- chat_whoami(client)$id + if (identity_mentioned(id, message)) { + return(TRUE) + } + body <- message$body %||% "" + # Slack does not leave a mention as text: the sending client rewrites + # it to <@U0123> before the message is stored, so this is a literal + # match and there is no @name form to also look for. Someone who + # typed the bot's display name without selecting the completion did + # not mention it, and Slack agrees -- no notification goes out. + nzchar(body) && grepl(sprintf("<@%s>", id), body, fixed = TRUE) +} diff --git a/inst/tinytest/test_contract.R b/inst/tinytest/test_contract.R index 21aa48c..f8d9a08 100644 --- a/inst/tinytest/test_contract.R +++ b/inst/tinytest/test_contract.R @@ -162,3 +162,48 @@ local({ expect_false(chat_capabilities(lo)$join) expect_error(chat_join(lo, "c"), "not supported by this adapter") }) + +# ---- Identity record ---- +id <- chat_identity("@bot:ex", display = "corteza") +expect_inherits(id, "chat_identity") +expect_identical(id$id, "@bot:ex") +expect_identical(id$display, "corteza") +expect_true(is.na(chat_identity("@bot:ex")$display)) +expect_true(is.na(chat_identity("@bot:ex", display = NULL)$display)) +expect_error(chat_identity("")) +expect_error(chat_identity(NULL)) +expect_error(chat_identity(c("@a:ex", "@b:ex"))) + +# An adapter that cannot say who it is says so, rather than answering NA +# and letting every self-check silently report "not me". +nothing <- structure(list(), class = c("chat_nothing", "chat_client")) +expect_error(chat_whoami(nothing), "not supported by this adapter") + +# ---- chat_addressed, the default ---- +# Declared mentions only. An adapter that does not override this +# under-reports: a bot that misses being addressed stays quiet, one that +# over-reports talks over people unprompted. +local({ + cl <- chat_loopback() + expect_identical(chat_whoami(cl)$id, "loopback") + m <- function(body, mentions = NULL) { + chat_message(id = "1", channel = "c", sender = "ann", body = body, + ts = Sys.time(), mentions = mentions) + } + expect_true(chat_addressed(cl, m("x", mentions = "loopback"))) + expect_true(chat_addressed(cl, m("x", mentions = c("ann", "loopback")))) + expect_false(chat_addressed(cl, m("x", mentions = "ann"))) + # The plain-text form is not read by the default, deliberately. + expect_false(chat_addressed(cl, m("hey loopback"))) + expect_false(chat_addressed(cl, m("x"))) + expect_false(chat_addressed(cl, m("x", mentions = character()))) + expect_true(chat_capabilities(cl)$whoami) +}) + +# The default needs an identity, so an adapter with neither reports the +# missing one rather than a bare FALSE. +expect_error(chat_addressed(nothing, + chat_message(id = "1", channel = "c", + sender = "a", body = "b", + ts = Sys.time())), + "not supported by this adapter") diff --git a/inst/tinytest/test_irc.R b/inst/tinytest/test_irc.R index 0c1a0d8..538479a 100644 --- a/inst/tinytest/test_irc.R +++ b/inst/tinytest/test_irc.R @@ -65,3 +65,28 @@ if (!is.null(ss)) { offline <- structure(list(), class = c("chat_irc", "chat_client")) expect_identical(chat_resolve(offline, "lab"), "#lab") expect_identical(chat_resolve(offline, "#lab"), "#lab") + +# ---- Identity ---- +local({ + cl <- structure(list(env = new.env(parent = emptyenv()), nick = "corteza"), + class = c("chat_irc", "chat_client")) + irc_msg <- function(body = "", mentions = NULL) { + chat_message(id = "1", channel = "#lab", sender = "ann", + body = body, ts = Sys.time(), mentions = mentions) + } + expect_identical(chat_whoami(cl)$id, "corteza") + expect_true(chat_capabilities(cl)$whoami) + + # IRC addressing is a naming convention and nothing else. + expect_true(chat_addressed(cl, irc_msg("corteza: do the thing"))) + expect_true(chat_addressed(cl, irc_msg("thanks corteza"))) + expect_true(chat_addressed(cl, irc_msg("CORTEZA, hello"))) + expect_false(chat_addressed(cl, irc_msg("corteza2 is the other one"))) + # "|away" and "_" are nick characters, so these are other clients of + # the same person rather than the nick that was addressed. + expect_false(chat_addressed(cl, irc_msg("corteza|away said so"))) + expect_false(chat_addressed(cl, irc_msg("ping corteza_"))) + expect_false(chat_addressed(cl, irc_msg("nobody here"))) + expect_false(chat_addressed(cl, irc_msg(""))) + expect_true(chat_addressed(cl, irc_msg("nothing", mentions = "corteza"))) +}) diff --git a/inst/tinytest/test_matrix.R b/inst/tinytest/test_matrix.R index 30c138a..95e3266 100644 --- a/inst/tinytest/test_matrix.R +++ b/inst/tinytest/test_matrix.R @@ -1474,3 +1474,82 @@ local({ expect_true(caps$join) expect_identical(caps$invites, chat.api:::matrix_invites_available()) }) + +# ---- Identity ---- +msg <- function(body = "", mentions = NULL) { + chat_message(id = "$1", channel = "!a:ex", sender = "@ann:ex", + body = body, ts = Sys.time(), mentions = mentions) +} + +local({ + who <- chat_whoami(seam_client()) + expect_inherits(who, "chat_identity") + expect_identical(who$id, "@bot:ex") + # The device id rides along in raw, the access token does not. An + # identity record is the kind of thing a consumer prints. + expect_identical(who$raw$device_id, "DEV1") + expect_false("access_token" %in% names(who$raw)) + expect_true(is.na(who$display)) +}) + +# A config with no user_id cannot say who it is, and says so. Answering +# NA would make every self-check report "not me" and the bot would reply +# to itself forever. +expect_error(chat_whoami(seam_client(mx = fake_mx(user_id = NULL))), + "no user_id") +expect_error(chat_whoami(seam_client(mx = fake_mx(user_id = ""))), + "no user_id") + +# Declared mentions, the structured signal. +expect_true(chat_addressed(seam_client(), msg(mentions = "@bot:ex"))) +expect_true(chat_addressed(seam_client(), + msg(mentions = c("@ann:ex", "@bot:ex")))) +expect_false(chat_addressed(seam_client(), msg(mentions = "@ann:ex"))) + +# The full user id typed out. +expect_true(chat_addressed(seam_client(), msg("hey @bot:ex look"))) + +# The localpart as a word. +expect_true(chat_addressed(seam_client(), msg("@bot do the thing"))) +expect_true(chat_addressed(seam_client(), msg("well @BOT?"))) +expect_true(chat_addressed(seam_client(), msg("ping @bot"))) + +# Not a mention: a longer localpart that merely starts the same way. +# "." and "-" are legal in a localpart, so a \\b boundary ends the word +# early and hands @bot.deploy's traffic to @bot. +expect_false(chat_addressed(seam_client(), msg("ask @bot2 instead"))) +expect_false(chat_addressed(seam_client(), msg("ask @bot.deploy instead"))) +expect_false(chat_addressed(seam_client(), msg("ask @bot-staging instead"))) +expect_false(chat_addressed(seam_client(), msg("ask @bot_x instead"))) + +# Nothing to match on. +expect_false(chat_addressed(seam_client(), msg(""))) +expect_false(chat_addressed(seam_client(), msg("no one in particular"))) + +# A localpart carrying regex metacharacters is matched literally, not +# compiled. "@a.bot" must not match "@axbot". +local({ + cl <- seam_client(mx = fake_mx(user_id = "@a.bot:ex")) + expect_true(chat_addressed(cl, msg("hi @a.bot"))) + expect_false(chat_addressed(cl, msg("hi @axbot"))) +}) + +# A user_id that is not shaped like one has no localpart to match. The +# empty string it yields must not reach the pattern: "@" with nothing +# after it matches a bare @ anywhere, so every message carrying an email +# address would read as a mention. +expect_identical(chat.api:::matrix_localpart("bot"), "") +expect_identical(chat.api:::matrix_localpart("@bot"), "") +expect_identical(chat.api:::matrix_localpart("@bot:ex.org"), "bot") +expect_identical(chat.api:::matrix_localpart("@bot:ex.org:8448"), "bot") +local({ + cl <- seam_client(mx = fake_mx(user_id = "bot")) + expect_false(chat_addressed(cl, msg("reach me @ 5pm"))) + expect_false(chat_addressed(cl, msg("ann@ex.org wrote in"))) + expect_false(chat_addressed(cl, msg("anything at all"))) + # The id itself is still matched literally, which is the most a + # malformed one supports. + expect_true(chat_addressed(cl, msg("bot"))) +}) + +expect_true(chat_capabilities(seam_client())$whoami) diff --git a/inst/tinytest/test_slack.R b/inst/tinytest/test_slack.R index 08b6780..57a72c7 100644 --- a/inst/tinytest/test_slack.R +++ b/inst/tinytest/test_slack.R @@ -405,3 +405,63 @@ local({ expect_false(caps$invites) expect_true(caps$join) }) + +# ---- Identity ---- +slack_msg <- function(body = "", mentions = NULL) { + chat_message(id = "1.1", channel = "lab", sender = "U999", + body = body, ts = Sys.time(), mentions = mentions) +} + +local({ + seen <- NULL + cl <- slack_api_client(function(path, ..., .method, token) { + seen <<- list(path = path, .method = .method, token = token) + list(ok = TRUE, user_id = "U0BOT", user = "corteza", + team = "cornball", bot_id = "B1") + }) + who <- chat_whoami(cl) + expect_identical(seen$path, "/api/auth.test") + expect_identical(seen$.method, "GET") + expect_identical(seen$token, "xoxb-test") + expect_identical(who$id, "U0BOT") + expect_identical(who$display, "corteza") +}) + +# One call per client, not one per message. chat_addressed() asks on +# every message, and the answer is a property of the token. +local({ + calls <- 0L + cl <- slack_api_client(function(...) { + calls <<- calls + 1L + list(ok = TRUE, user_id = "U0BOT", user = "corteza") + }) + chat_whoami(cl) + chat_whoami(cl) + chat_addressed(cl, slack_msg("hi <@U0BOT>")) + expect_identical(calls, 1L) +}) + +# Slack refuses in the body with HTTP 200, so an invalid token comes back +# looking like a successful call that just did not say who we are. +expect_error(chat_whoami(slack_api_client(function(...) { + list(ok = FALSE, error = "invalid_auth") + })), "invalid_auth") +expect_error(chat_whoami(slack_api_client(function(...) list(ok = TRUE))), + "no user_id") + +local({ + cl <- slack_api_client(function(...) list(ok = TRUE, user_id = "U0BOT", + user = "corteza")) + # The ref form Slack stores. There is no plain "@corteza" to find: + # the sending client rewrote it before the message existed. + expect_true(chat_addressed(cl, slack_msg("hey <@U0BOT> look"))) + expect_false(chat_addressed(cl, slack_msg("hey @corteza look"))) + expect_false(chat_addressed(cl, slack_msg("hey <@U0OTHER> look"))) + # A ref for a longer id that starts the same way is someone else. + expect_false(chat_addressed(cl, slack_msg("hey <@U0BOTS> look"))) + expect_false(chat_addressed(cl, slack_msg(""))) + # Declared mentions still count, for a poll path that populates them. + expect_true(chat_addressed(cl, slack_msg("nothing", mentions = "U0BOT"))) +}) + +expect_true(chat_capabilities(slack_api_client(function(...) NULL))$whoami) diff --git a/man/chat_addressed.Rd b/man/chat_addressed.Rd new file mode 100644 index 0000000..a6eb208 --- /dev/null +++ b/man/chat_addressed.Rd @@ -0,0 +1,46 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_addressed} +\alias{chat_addressed} +\title{Does a message address this client?} +\usage{ +chat_addressed(client, message, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{message}{A \code{\link{chat_message}}.} + +\item{...}{Adapter-specific options.} +} +\value{ +\code{TRUE} or \code{FALSE}. +} +\description{ +Answers the question a bot in a room full of people has to ask before +replying. Two signals feed it, and which ones exist is the adapter's +business rather than the caller's: +} +\details{ +\enumerate{ +\item The message's declared mentions -- Matrix \code{m.mentions}, +a Slack user ref. Structured, unambiguous, and the only signal the +default method reads. +\item The plain-text conventions of the transport. Matrix has +\code{@@bot} and the full user id, Slack has \code{<@U0123>}, IRC +has a leading \code{nick:}. These are the reason this is a verb +and not a field: writing the Matrix form into a consumer is how +that consumer ends up knowing it is talking to Matrix. +} + +The default reads only the declared mentions, so an adapter that does +not override it under-reports rather than over-reports. A bot that +misses being addressed stays quiet; one that thinks it was addressed +when it was not talks over people, and unprompted is worse than +absent. + +} +\examples{ +cl <- chat_loopback() +chat_send(cl, "general", "hi") +chat_addressed(cl, chat_poll(cl)$messages[[1L]]) +} diff --git a/man/chat_capabilities.Rd b/man/chat_capabilities.Rd index 589ece0..6cf661d 100644 --- a/man/chat_capabilities.Rd +++ b/man/chat_capabilities.Rd @@ -19,7 +19,9 @@ A list with at least: \code{threads} (can post into (\code{\link{chat_channel_info}} works), \code{members} (\code{\link{chat_members}} works), \code{invites} (invitations come back out of \code{\link{chat_poll}}), \code{join} - (\code{\link{chat_join}} works), \code{files}, \code{typing}, + (\code{\link{chat_join}} works), \code{whoami} + (\code{\link{chat_whoami}} works, and with it the default + \code{\link{chat_addressed}}), \code{files}, \code{typing}, \code{e2ee}, \code{identity_override} (logicals), \code{markup_dialects} (character), \code{max_message_bytes} (integer or NA). diff --git a/man/chat_identity.Rd b/man/chat_identity.Rd new file mode 100644 index 0000000..e06c63a --- /dev/null +++ b/man/chat_identity.Rd @@ -0,0 +1,30 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_identity} +\alias{chat_identity} +\title{Construct an identity record} +\usage{ +chat_identity(id, display = NA_character_, raw = NULL) +} +\arguments{ +\item{id}{The account identifier, in whatever form the transport +uses. Comparable against \code{\link{chat_message}}'s \code{sender} +and against the entries of its \code{mentions}: that comparability +is the point of the field, so an adapter whose two sides disagree +has a bug here rather than a choice.} + +\item{display}{Human-readable name, or NA when the adapter would have +to ask the server for it. Never used for matching -- it is not +unique, and on most transports any account can set it to any other +account's. For logs and prompts only.} + +\item{raw}{The adapter's platform-native identity payload.} +} +\value{ +A list with class \code{chat_identity}. +} +\description{ +Construct an identity record +} +\examples{ +chat_identity("@bot:example.org", display = "corteza") +} diff --git a/man/chat_whoami.Rd b/man/chat_whoami.Rd new file mode 100644 index 0000000..f1d9136 --- /dev/null +++ b/man/chat_whoami.Rd @@ -0,0 +1,29 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{chat_whoami} +\alias{chat_whoami} +\title{Who is this client logged in as?} +\usage{ +chat_whoami(client, ...) +} +\arguments{ +\item{client}{A \code{chat_client}.} + +\item{...}{Adapter-specific options.} +} +\value{ +A \code{\link{chat_identity}}. +} +\description{ +The client's own account, as the transport identifies it. +} +\details{ +The default method throws rather than guessing. Every use of an +identity is a comparison -- is this message mine, did someone address +me -- and a wrong answer to either is silent: the bot answers itself +in a loop, or never answers anyone. An adapter that cannot say who it +is should say so. + +} +\examples{ +chat_whoami(chat_loopback()) +}