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
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
172 changes: 172 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
132 changes: 131 additions & 1 deletion R/contract.R
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
}
Loading
Loading