| Title: | Transport-Agnostic Chat Contract |
|---|---|
| Description: | A transport-agnostic contract for chat-room connectivity: connect, poll, and send against one interface, with adapters for 'Matrix' <https://spec.matrix.org/>, 'Slack' <https://api.slack.com/>, 'Telegram' <https://core.telegram.org/bots/api>, and Internet Relay Chat (IRC). An in-memory adapter supports local testing. Capability flags describe support for threads, markup dialects, encryption, and per-message identity. Platform clients are supplied by optional packages; the core interface uses only base R. |
| Authors: | Troy Hernandez [aut, cre] (ORCID: <https://orcid.org/0009-0005-4248-604X>), cornball.ai [cph] |
| Maintainer: | Troy Hernandez <[email protected]> |
| License: | Apache License (>= 2) |
| Version: | 0.1.0 |
| Built: | 2026-09-27 18:39:43 UTC |
| Source: | https://github.com/cran/chat.api |
A common interface for chat messages, attachments, rooms, and identity.
Use chat_loopback for local development, or connect through
chat_matrix, chat_irc,
chat_slack, or chat_telegram.
Inspect chat_capabilities before using optional operations.
cl <- chat_loopback() chat_send(cl, "general", "hello") chat_poll(cl)$messagescl <- chat_loopback() chat_send(cl, "general", "hello") chat_poll(cl)$messages
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:
chat_addressed(client, message, ...)chat_addressed(client, message, ...)
client |
A |
message |
A |
... |
Adapter-specific options. |
The message's declared mentions – Matrix m.mentions,
a Slack user ref. Structured, unambiguous, and the only signal the
default method reads.
The plain-text conventions of the transport. Matrix has
@@bot and the full user id, Slack has <@U0123>, IRC
has a leading 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.
TRUE or FALSE.
cl <- chat_loopback() chat_send(cl, "general", "hi") chat_addressed(cl, chat_poll(cl)$messages[[1L]])cl <- chat_loopback() chat_send(cl, "general", "hi") chat_addressed(cl, chat_poll(cl)$messages[[1L]])
Inbound media on a chat_message: chat_poll()
and chat_history() put these in the message's
attachments on adapters whose
chat_capabilities()$attachments is TRUE.
chat_attachment( id, name = NA_character_, mime = NA_character_, bytes = NA_integer_, url = NA_character_, path = NA_character_, sha256 = NA_character_, raw = NULL )chat_attachment( id, name = NA_character_, mime = NA_character_, bytes = NA_integer_, url = NA_character_, path = NA_character_, sha256 = NA_character_, raw = NULL )
id |
Adapter-native identifier for the content (a Matrix mxc URI, a Slack file id). The stable handle; everything else here is description. |
name |
Filename as the sender labeled it, or NA. |
mime |
MIME type, or NA when the transport does not say. |
bytes |
Size in bytes, or NA. |
url |
A fetchable location, or NA. It may require this client's credentials; a consumer must not assume it is public. |
path |
Local filesystem path when the content is already on disk, or NA. |
sha256 |
Content hash, or NA. Adapters fill it only when the transport carries one (Matrix encrypted attachments do); they must not compute it speculatively, because NA meaning "unverified" is what tells a consumer that needs provenance to hash at ingest and record the result. |
raw |
The adapter's platform-native payload. |
A list with class chat_attachment.
chat_attachment("mxc://ex/abc", name = "plot.png", mime = "image/png")chat_attachment("mxc://ex/abc", name = "plot.png", mime = "image/png")
Describe what a chat client's platform supports
chat_capabilities(client, ...)chat_capabilities(client, ...)
client |
A |
... |
Adapter-specific options. |
A list with at least: threads (can post into
threads), thread_replies (thread replies come back out of
chat_poll), edits, reactions
(chat_react works), reaction_events (reactions
come back out of chat_poll), channel_info
(chat_channel_info works), members
(chat_members works), invites (invitations come
back out of chat_poll), join
(chat_join works), whoami
(chat_whoami works, and with it the default
chat_addressed), channel_create
(chat_channel_create works), leave
(chat_leave works), set_state
(durable channel state: both chat_set_state and
chat_get_state work – a transport has the pair or
neither), files (outbound:
chat_send(files =) works), attachments (inbound:
media comes back out of chat_poll as
chat_attachment records), typing,
e2ee, identity_override (logicals),
user_identity (a send can authenticate as a real member of
the platform rather than as the bot – Slack's
chat_send(as_user = TRUE) with a user token; a property of
the client instance's configuration there, and FALSE everywhere
else), markup_dialects (character),
max_message_bytes (integer or NA).
Sending and receiving get separate flags wherever a platform does
one and not the other, which is why threads and
thread_replies are two entries rather than one. Reactions
split the same way: Slack can place one, and does not report anyone
else's through the history endpoint this adapter polls, so a
consumer reading a single flag would wait forever for events that
never arrive.
caps <- chat_capabilities(chat_loopback()) caps$threads caps$e2eecaps <- chat_capabilities(chat_loopback()) caps$threads caps$e2ee
Capability-gated: check chat_capabilities()$channel_create.
Bots open rooms about as often as they are invited to them; a
contract without creation forces every such consumer below the
seam, into adapter-native calls.
chat_channel_create(client, name, ...)chat_channel_create(client, name, ...)
client |
A |
name |
Character. Human-readable name for the new channel. |
... |
Adapter-specific options (topic, visibility, invitees). |
The new channel's identifier, invisibly. Everything else – sends, joins, membership – takes the identifier, not the name, so the return value is the point of the call.
cl <- chat_loopback() id <- chat_channel_create(cl, "general") chat_send(cl, id, "hello")cl <- chat_loopback() id <- chat_channel_create(cl, "general") chat_send(cl, id, "hello")
Returns the channel's descriptive metadata: what it is called and what it is for.
chat_channel_info(client, channel, ...)chat_channel_info(client, channel, ...)
client |
A |
channel |
Channel/room identifier. |
... |
Adapter-specific options. |
Membership is deliberately not here, and chat_members is
a separate verb. The two look like one lookup and are not: a name and
a topic are two short strings that change rarely, while a member list
is unbounded and changes constantly. Bundling them makes every read of
a topic pay for a member list, which on a busy room is the expensive
part – and a consumer caches the two on different schedules for
exactly that reason.
A NULL field means the channel has no such thing: a Matrix room with
no m.room.name really has no name. An adapter that cannot
answer at all throws, so "cannot ask" and "asked, and there is none"
stay distinguishable. Check chat_capabilities()$channel_info
first.
A list with id, name, and topic.
id is the channel as the platform addresses it; name
and topic are character or NULL.
## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_channel_info(cl, "#general:example.org") ## End(Not run)## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_channel_info(cl, "#general:example.org") ## End(Not run)
The state half of the contract. chat_poll answers "what
changed since my cursor"; this and its siblings answer "what is true
now", which is the question a process asks when it starts up with no
useful cursor at all.
chat_channels(client, ...)chat_channels(client, ...)
client |
A |
... |
Adapter-specific options. |
Character vector of channel identifiers.
chat_channels(chat_loopback())chat_channels(chat_loopback())
Construct a chat_config
chat_config(x, app = NULL, path = NULL)chat_config(x, app = NULL, path = NULL)
x |
A named list of configuration fields. |
app |
Application namespace the config belongs to, or NULL. |
path |
File the config was read from, or NULL for one that has never been written. |
A list with class chat_config.
chat_config(list(server = "https://ex.invalid", user = "bot"), app = "demo")chat_config(list(server = "https://ex.invalid", user = "bot"), app = "demo")
Writes to the file the config came from, at mode 0600.
chat_config_save(config, app = NULL, path = NULL)chat_config_save(config, app = NULL, path = NULL)
config |
A |
app |
Override the app namespace the config was loaded under. |
path |
Override the file to write. |
The config, invisibly.
if (requireNamespace("mx.client", quietly = TRUE)) { path <- tempfile(fileext = ".json") cfg <- chat_config(list(server = "https://matrix.example.org", token = "example-token", user_id = "@bot:example.org")) chat_config_save(cfg, path = path) file.exists(path) unlink(path) }if (requireNamespace("mx.client", quietly = TRUE)) { path <- tempfile(fileext = ".json") cfg <- chat_config(list(server = "https://matrix.example.org", token = "example-token", user_id = "@bot:example.org")) chat_config_save(cfg, path = path) file.exists(path) unlink(path) }
The default method is a no-op: HTTP-poll transports have nothing to close. Persistent-socket transports (IRC) override it.
chat_disconnect(client, ...)chat_disconnect(client, ...)
client |
A |
... |
Adapter-specific options. |
TRUE, invisibly.
cl <- chat_loopback() chat_disconnect(cl)cl <- chat_loopback() chat_disconnect(cl)
Inbound attachments name where their content lives, not what it is:
chat_attachment's url is a platform handle that
usually needs this client's credentials, so a consumer cannot simply
download it. This is the verb that turns one into a file on disk.
chat_download(client, attachment, dest = NULL, ...)chat_download(client, attachment, dest = NULL, ...)
client |
A |
attachment |
A |
dest |
Destination path. NULL picks a temporary file, keeping
the attachment's extension where it has one. The caller should remove
temporary downloads with |
... |
Adapter-specific options. |
Capability-gated on chat_capabilities()$attachments, the same
flag that says inbound media arrives at all. The default method
throws, on chat_react's reasoning: a fetch that
quietly did nothing leaves the caller pointing at a path with no
bytes behind it.
An attachment already on disk (path set, as the loopback
adapter records) is copied rather than fetched, so a consumer needs
one code path for both.
The destination path, invisibly.
cl <- chat_loopback() src <- tempfile(fileext = ".txt") writeLines("hello", src) chat_send(cl, "general", "a file", files = src) attachment <- chat_poll(cl)$messages[[1L]]$attachments[[1L]] dest <- chat_download(cl, attachment) readLines(dest) unlink(c(src, dest))cl <- chat_loopback() src <- tempfile(fileext = ".txt") writeLines("hello", src) chat_send(cl, "general", "a file", files = src) attachment <- chat_poll(cl)$messages[[1L]]$attachments[[1L]] dest <- chat_download(cl, attachment) readLines(dest) unlink(c(src, dest))
What makes a progress message possible: post "working on it", then keep replacing it as the work happens, instead of narrating into the channel one message at a time.
chat_edit( client, channel, message_id, text, markup = c("plain", "markdown"), rich = NULL, kind = "message", ... )chat_edit( client, channel, message_id, text, markup = c("plain", "markdown"), rich = NULL, kind = "message", ... )
client |
A |
channel |
Channel/room identifier. |
message_id |
The message to replace, as returned by
|
text |
The replacement text, in full. Not a delta: every platform that supports this takes the whole new body, and a contract that took a patch would have to reconstruct the old one to apply it. |
markup |
|
rich |
Adapter-native markup for the replacement, as
|
kind |
Message kind, as |
... |
Adapter-specific options. |
The default throws. An edit that silently does nothing leaves the old
text on screen, and stale content is worse than a visible failure –
the reader has no way to tell that what they are looking at is no
longer true. Check chat_capabilities()$edits.
The identifier of the event the edit created where the platform makes one (Matrix), or of the edited message where it does not (Slack), invisibly.
That the edit is what readers see. A client that does not implement edits shows the original and an "* edited" fallback beside it, and notifications almost always carry the text as first sent. So the first version has to stand on its own – "working on it" is a fine thing to be paged with, a half-finished sentence is not.
cl <- chat_loopback() id <- chat_send(cl, "general", "Working on it") chat_edit(cl, "general", id, "Finished") chat_history(cl, "general")$messagescl <- chat_loopback() id <- chat_send(cl, "general", "Working on it") chat_edit(cl, "general", id, "Finished") chat_history(cl, "general")$messages
The counterpart of chat_set_state, reading back what
it wrote. Capability-gated on the same
chat_capabilities()$set_state: a transport with durable
channel state has both halves or neither.
chat_get_state(client, channel, type, state_key = "", ...)chat_get_state(client, channel, type, state_key = "", ...)
client |
A |
channel |
Channel/room identifier. |
type |
Character. Namespaced event type. |
state_key |
Character. Sub-key within the type, defaulting to the empty string. |
... |
Adapter-specific options. |
Absent state is NULL, not an error. "Nothing was ever
written here" is an ordinary answer for a caller checking whether a
marker is set, and one it should not have to wrap in a handler. A
state store that cannot be reached at all still errors, because
that is a different fact.
The stored content as a named list, or NULL when no
state is set for that type/state_key pair.
cl <- chat_loopback() chat_get_state(cl, "general", "m.room.topic") # NULL until written chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning")) chat_get_state(cl, "general", "m.room.topic")cl <- chat_loopback() chat_get_state(cl, "general", "m.room.topic") # NULL until written chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning")) chat_get_state(cl, "general", "m.room.topic")
Independent of the poll cursor: a restarted process uses this to recover the context it lost, and asking for it must not move the cursor or consume anything.
chat_history(client, channel, limit = 50L, cursor = NULL, ...)chat_history(client, channel, limit = 50L, cursor = NULL, ...)
client |
A |
channel |
Channel/room identifier. |
limit |
Maximum messages to return. |
cursor |
Opaque continuation token from a previous call's
|
... |
Adapter-specific options. |
A list with messages (list of chat_message,
oldest first) and cursor (opaque; pass it back to read
further into the past, NULL when the channel has no more history).
Not a message id. This started out taking one and it was wrong on the
reference transport: Matrix's /messages takes a pagination
token from a previous response, and handing it an event id does not
page from that event – it fails, or worse, silently returns the wrong
window. Slack pages by its own next_cursor. There is no id that
means the same thing on both, so the contract does what it already
does for chat_poll: the token is the adapter's, and a
consumer only ever passes back what it was given.
Chronological, oldest first, whatever the platform's native direction
is. Matrix dir = "b" and Slack conversations.history
both hand back newest-first and every consumer replaying history into
a transcript has to flip it. One flip in the adapter beats one per
consumer, and a consumer that gets it wrong produces a transcript
that reads backwards without erroring.
Note that pages run backwards while each page runs forwards: call it twice and the second page's messages all precede the first page's. A consumer assembling a full transcript prepends.
The same message can arrive from both, and adapters must return the
same id for it either way. That id is the only thing a consumer
has to deduplicate on – a startup backfill and the first poll after
it routinely cover the same events.
cl <- chat_loopback() chat_send(cl, "general", "hello") chat_history(cl, "general")$messagescl <- chat_loopback() chat_send(cl, "general", "hello") chat_history(cl, "general")$messages
Construct an identity record
chat_identity(id, display = NA_character_, raw = NULL)chat_identity(id, display = NA_character_, raw = NULL)
id |
The account identifier, in whatever form the transport
uses. Comparable against |
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. |
raw |
The adapter's platform-native identity payload. |
A list with class chat_identity.
chat_identity("@bot:example.org", display = "corteza")chat_identity("@bot:example.org", display = "corteza")
The record chat_poll returns in $invites on
adapters whose chat_capabilities()$invites is TRUE.
chat_invite(channel, inviter = NA_character_, raw = NULL)chat_invite(channel, inviter = NA_character_, raw = NULL)
channel |
Channel/room identifier the client has been invited to
(character). Pass it to |
inviter |
Who issued the invitation, or |
raw |
The adapter's platform-native payload. |
A list with class chat_invite.
Unlike chat_message and chat_reaction,
there is no ts. An invitation is a standing state rather than
an event at a moment, and Matrix's stripped invite state carries no
reliable origin_server_ts to report. A field that could only
ever be NA is worse than no field.
chat_invite("!room:example.org", inviter = "@alice:example.org")chat_invite("!room:example.org", inviter = "@alice:example.org")
Connects, registers (NICK/USER), and joins channels. The
persistent socket buffers into chat_poll: each poll
drains available lines, answers server PINGs, and returns PRIVMSGs
as normalized messages. The cursor is a message counter.
chat_irc(host, port = 6667L, nick, channels = character(), realname = nick)chat_irc(host, port = 6667L, nick, channels = character(), realname = nick)
host |
Server hostname. |
port |
Server port (plaintext; commonly 6667). |
nick |
Nickname to register. |
channels |
Character vector of channels to join (e.g.
|
realname |
Real-name field for USER registration. |
A chat_client of class chat_irc.
## Not run: # Requires a reachable IRC server and permission to join the channel. cl <- chat_irc(host = "irc.example.org", nick = "example_bot", channels = "#example") chat_poll(cl, timeout = 1) chat_disconnect(cl) ## End(Not run)## Not run: # Requires a reachable IRC server and permission to join the channel. cl <- chat_irc(host = "irc.example.org", nick = "example_bot", channels = "#example") chat_poll(cl, timeout = 1) chat_disconnect(cl) ## End(Not run)
Accepts a pending invitation, or joins an open channel where the platform allows it.
chat_join(client, channel, ...)chat_join(client, channel, ...)
client |
A |
channel |
Channel/room identifier, as carried on a
|
... |
Adapter-specific options. |
The default method throws, on the same reasoning as
chat_react: a join that silently does nothing leaves the
caller believing it is in a room it will never hear from. Check
chat_capabilities()$join.
The joined channel's identifier, invisibly.
## Not run: # Requires a saved Matrix configuration and access to the room. cl <- chat_matrix(app = "mybot") chat_join(cl, "#general:example.org") ## End(Not run)## Not run: # Requires a saved Matrix configuration and access to the room. cl <- chat_matrix(app = "mybot") chat_join(cl, "#general:example.org") ## End(Not run)
The inverse of chat_join: after it returns, the
client stops receiving the channel's traffic, on platforms where
membership is a thing at all. Capability-gated: check
chat_capabilities()$leave.
chat_leave(client, channel, ...)chat_leave(client, channel, ...)
client |
A |
channel |
Channel/room identifier. |
... |
Adapter-specific options. |
The left channel's identifier, invisibly.
## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_leave(cl, "#general:example.org") ## End(Not run)## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_leave(cl, "#general:example.org") ## End(Not run)
Messages sent to any channel are appended to an in-memory log and
come back out of chat_poll. The cursor is the integer
position in that log.
chat_loopback()chat_loopback()
A chat_client of class chat_loopback.
cl <- chat_loopback() chat_send(cl, "general", "hello") chat_poll(cl)$messagescl <- chat_loopback() chat_send(cl, "general", "hello") chat_poll(cl)$messages
The default is a quiet FALSE, on chat_typing's
reasoning rather than chat_react's: a read marker that
does not appear costs a human a little context about what the bot has
seen, and nothing more. Nobody is waiting on it the way they wait on
an acknowledgement.
chat_mark_read(client, channel, message_id, ...)chat_mark_read(client, channel, message_id, ...)
client |
A |
channel |
Channel/room identifier. |
message_id |
The message to mark read, and everything before it. |
... |
Adapter-specific options. |
Write-only. Reading other participants' read state is a much larger surface – per-user, per-device, and absent entirely on some platforms – and no consumer needs it yet.
TRUE if the marker was sent, FALSE otherwise, invisibly.
cl <- chat_loopback() id <- chat_send(cl, "general", "hello") chat_mark_read(cl, "general", id) # FALSE: no read markerscl <- chat_loopback() id <- chat_send(cl, "general", "hello") chat_mark_read(cl, "general", id) # FALSE: no read markers
Wraps an mx.client client config (see
mx.client::mx_client_load()). The sync cursor lives inside the
mx.client config; with save_cursor = TRUE every poll persists
it, so a restarted process resumes where it left off.
chat_matrix( app = NULL, path = NULL, save_cursor = TRUE, mx = NULL, relogin = TRUE, e2ee = FALSE, crypto_store = NULL, .sync = NULL, .extract = NULL, .send = NULL, .media = NULL, .typing = NULL, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .create = NULL, .leave = NULL, .state = NULL, .get_state = NULL, .extract_media = NULL, .download = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, .edit = NULL, .rich = NULL )chat_matrix( app = NULL, path = NULL, save_cursor = TRUE, mx = NULL, relogin = TRUE, e2ee = FALSE, crypto_store = NULL, .sync = NULL, .extract = NULL, .send = NULL, .media = NULL, .typing = NULL, .crypto = NULL, .save = NULL, .react = NULL, .info = NULL, .members = NULL, .join = NULL, .create = NULL, .leave = NULL, .state = NULL, .get_state = NULL, .extract_media = NULL, .download = NULL, .channels = NULL, .history = NULL, .pending = NULL, .read = NULL, .identity = NULL, .edit = NULL, .rich = NULL )
app |
Application namespace passed to
|
path |
Explicit config path, or NULL for the app default. |
save_cursor |
Logical. Persist the sync token after each poll,
through mx.client, into the config file the wrapped client points
at. Pass FALSE only if you persist |
mx |
A ready mx.client client config to wrap, for consumers
that already load and manage one; NULL (default) loads via
|
relogin |
Logical. Wrap each sync in
|
e2ee |
Logical. Own Olm/Megolm state in the adapter, so
Three things change when it is on. The crypto state is built on
first use rather than here: from |
crypto_store |
Character or NULL. Directory holding the pickled
Olm account and sessions. NULL derives it from A Matrix device has one Olm identity for its whole life, and three
things hold that. The store records the That last check distinguishes three cases: no published device is a
first run, a published device whose keys match is this account, and
a published device that either differs or fails signature
verification is an error. A A Windows drive-relative |
.sync |
Testing seam: replacement for
|
.extract |
Testing seam: replacement for
|
.send |
Testing seam: replacement for
|
.media |
Testing seam: replacement for
|
.typing |
Testing seam: replacement for
|
.crypto |
Testing seam: a named list overriding any of the
adapter's four crypto operations – |
.save |
Testing seam: replacement for
|
.react |
Testing seam: replacement for |
.info |
Testing seam: a list with |
.members |
Testing seam: replacement for
|
.join |
Testing seam: replacement for
|
.create |
Testing seam: replacement for
|
.leave |
Testing seam: replacement for
|
.state |
Testing seam: replacement for
|
.get_state |
Testing seam: replacement for
|
.extract_media |
Testing seam: replacement for
|
.download |
Testing seam: replacement for
|
.channels |
Testing seam: replacement for
|
.history |
Testing seam: replacement for
|
.pending |
Testing seam: replacement for |
.read |
Testing seam: replacement for
|
.identity |
Testing seam: replacement for
|
.edit |
Testing seam: replacement for |
.rich |
Testing seam: replacement for |
A chat_client of class chat_matrix.
chat_poll on this class returns first_run and
client alongside messages, cursor, and
raw. first_run is TRUE when the sync started from no
cursor, so the messages are a backfill baseline rather than new
traffic. client is the post-sync mx.client config, which a
consumer needs whenever it drives mx.api directly (read receipts,
member lookups) because a relogin may have replaced the token this
poll cycle.
## Not run: # Requires mx.client and saved Matrix credentials for this application. cl <- chat_matrix(app = "mybot") chat_capabilities(cl) chat_poll(cl, timeout = 0) ## End(Not run)## Not run: # Requires mx.client and saved Matrix credentials for this application. cl <- chat_matrix(app = "mybot") chat_capabilities(cl) chat_poll(cl, timeout = 0) ## End(Not run)
Reads the credentials an application saved, and returns them as a
chat_config: a list carrying the transport's own fields plus
whatever else the application stored alongside them, with the app
namespace and file path attached as attributes so
chat_config_save can write it back where it came from.
chat_matrix_config(app = NULL, path = NULL, env_var = NULL)chat_matrix_config(app = NULL, path = NULL, env_var = NULL)
app |
Application namespace, e.g. |
path |
Explicit file path, overriding |
env_var |
Name of an environment variable that, when set, overrides both. |
A list with class chat_config.
Applications keep their own settings in the same file – which accounts count as bots, who may open a private conversation, a preferred model. Those are the application's, not the transport's, and a loader that dropped them would make the file unreadable by its owner. They pass through untouched and unvalidated.
if (requireNamespace("mx.client", quietly = TRUE)) { path <- tempfile(fileext = ".json") cfg <- chat_config(list(server = "https://matrix.example.org", token = "example-token", user_id = "@bot:example.org")) chat_config_save(cfg, path = path) chat_matrix_config(path = path) unlink(path) }if (requireNamespace("mx.client", quietly = TRUE)) { path <- tempfile(fileext = ".json") cfg <- chat_config(list(server = "https://matrix.example.org", token = "example-token", user_id = "@bot:example.org")) chat_config_save(cfg, path = path) chat_matrix_config(path = path) unlink(path) }
Where a Matrix configuration lives
chat_matrix_config_path(app, env_var = NULL, legacy = FALSE)chat_matrix_config_path(app, env_var = NULL, legacy = FALSE)
app |
Application namespace. |
env_var |
Name of an environment variable that overrides the default path, or NULL. |
legacy |
Return the pre- |
The file path (character).
if (requireNamespace("mx.client", quietly = TRUE)) { chat_matrix_config_path("demo") }if (requireNamespace("mx.client", quietly = TRUE)) { chat_matrix_config_path("demo") }
Logs in to a homeserver, resolves the room, and writes the resulting credentials.
chat_matrix_configure( server, user, password, room = NULL, app = NULL, path = NULL, device_id = NULL, extra = list() )chat_matrix_configure( server, user, password, room = NULL, app = NULL, path = NULL, device_id = NULL, extra = list() )
server |
Homeserver base URL. |
user |
Localpart or full user id. |
password |
Account password. |
room |
Room id or alias to record as the default. |
app |
Application namespace to save under. |
path |
Explicit path to save to, overriding |
device_id |
Device id to log in as. Reusing one keeps an existing E2EE identity; a new one starts a new device. |
extra |
Named list of application fields to store alongside the credentials. |
A chat_config, invisibly.
## Not run: # Requires a real homeserver, account password, and access to the room. pw <- Sys.getenv("MATRIX_PASSWORD") cfg <- chat_matrix_configure(server = "https://matrix.example.org", user = "bot", password = pw, room = "#lab:example.org", app = "mybot") ## End(Not run)## Not run: # Requires a real homeserver, account password, and access to the room. pw <- Sys.getenv("MATRIX_PASSWORD") cfg <- chat_matrix_configure(server = "https://matrix.example.org", user = "bot", password = pw, room = "#lab:example.org", app = "mybot") ## End(Not run)
Separate from chat_channel_info because it is the
expensive half: a member list is unbounded where a name and a topic
are two short strings, and it goes stale on a different schedule.
chat_members(client, channel, ...)chat_members(client, channel, ...)
client |
A |
channel |
Channel/room identifier. |
... |
Adapter-specific options. |
Character vector of member identifiers. Empty when the channel
has none; an adapter that cannot answer throws, so an empty room is
never confused with an unanswerable question. Check
chat_capabilities()$members first.
## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_members(cl, "#general:example.org") ## End(Not run)## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") chat_members(cl, "#general:example.org") ## End(Not run)
The record every adapter's chat_poll returns.
chat_message( id, channel, sender, body, ts, thread = NULL, markup = "plain", kind = "message", self = NULL, mentions = NULL, raw = NULL, encrypted = FALSE, sender_verified = NULL, attachments = NULL )chat_message( id, channel, sender, body, ts, thread = NULL, markup = "plain", kind = "message", self = NULL, mentions = NULL, raw = NULL, encrypted = FALSE, sender_verified = NULL, attachments = NULL )
id |
Message identifier (character). |
channel |
Channel/room identifier (character). |
sender |
Sender identifier (character). |
body |
Message text (character). |
ts |
POSIXct timestamp of when the platform recorded the
message, or |
thread |
Thread identifier or NULL. |
markup |
Source markup hint (character, e.g. "plain", "html"). |
kind |
Message kind (character; "message" default). Contract
vocabulary, not platform vocabulary: |
self |
Logical: did this client send the message? Poll returns the bot's own traffic like any other, so a consumer that replies to inbound mail needs this to avoid answering itself. NULL when the adapter cannot tell. |
mentions |
Character vector of user identifiers the message
explicitly mentioned (Matrix |
raw |
The adapter's platform-native payload for this message, exactly as the transport layer handed it over. Shape is adapter-specific and may already be normalized by the transport package (Matrix hands over an extracted record, not the timeline event), so it is an escape hatch, not a guarantee of completeness. |
encrypted |
Logical: did this message arrive end-to-end encrypted? FALSE on transports without E2EE and on cleartext messages in rooms that have it. |
sender_verified |
Logical: does the sender identifier bind to a device whose keys this client verified? NULL on cleartext messages, where the transport asserts the sender and there is nothing to verify. On an encrypted message FALSE is a real answer, not a missing one: the payload decrypted, but its claimed sender could not be tied to a verified device, so the identifier is the homeserver's word rather than cryptographic fact. |
attachments |
List of |
A list with class chat_message.
chat_message("m1", "general", "alice", "hello", ts = as.POSIXct("2026-01-01", tz = "UTC"))chat_message("m1", "general", "alice", "hello", ts = as.POSIXct("2026-01-01", tz = "UTC"))
Today: pending invitations. chat_poll reports an
invitation when it arrives, which is no help to a client that was not
running at the time – and some homeservers only report invites newer
than the since token, so the poll loop never sees them again.
chat_pending(client, ...)chat_pending(client, ...)
client |
A |
... |
Adapter-specific options. |
This is a separate verb rather than a mode of chat_poll
deliberately. Overloading the cursor would make "start from nothing"
and "tell me what is standing" the same call, and a client that asked
for pending invitations and thereby reset its read position would
replay every channel it is in.
A list with invites, a list of chat_invite.
## Not run: # Requires a saved Matrix configuration and a homeserver connection. client <- chat_matrix(app = "mybot") pending <- chat_pending(client) for (iv in pending$invites) chat_join(client, iv$channel) ## End(Not run)## Not run: # Requires a saved Matrix configuration and a homeserver connection. client <- chat_matrix(app = "mybot") pending <- chat_pending(client) for (iv in pending$invites) chat_join(client, iv$channel) ## End(Not run)
Poll-shaped everywhere: long-poll transports (Matrix /sync, Telegram
getUpdates) map directly; persistent-socket transports (IRC) buffer
into the poll. The cursor is opaque and adapter-specific; pass the
returned cursor back as since on the next call.
chat_poll(client, since = NULL, timeout = NULL, ...)chat_poll(client, since = NULL, timeout = NULL, ...)
client |
A |
since |
Opaque cursor from the previous poll, or NULL to start. |
timeout |
Seconds to wait for activity; NULL for the adapter default. |
... |
Adapter-specific options. |
A list with messages (list of chat_message) and
cursor (opaque, for the next since).
cl <- chat_loopback() chat_send(cl, "general", "hello") batch <- chat_poll(cl) batch$messages chat_poll(cl, since = batch$cursor)$messagescl <- chat_loopback() chat_send(cl, "general", "hello") batch <- chat_poll(cl) batch$messages chat_poll(cl, since = batch$cursor)$messages
Places a reaction (an emoji or short key) on an existing message.
chat_react(client, channel, message_id, key, ...)chat_react(client, channel, message_id, key, ...)
client |
A |
channel |
Channel/room identifier. |
message_id |
Identifier of the message being reacted to, as
returned by |
key |
The reaction itself. Platforms differ on what they accept:
Matrix takes any string and conventionally an emoji character,
Slack takes a short name without colons ( |
... |
Adapter-specific options. |
The default method throws. A reaction that silently does nothing is
worse than one that fails: the caller believes it acknowledged
something and no one can see that it did not. This differs from
chat_typing, whose default is a quiet FALSE, because a
missing typing indicator costs nothing and a missing acknowledgement
can be the whole message. Check chat_capabilities()$reactions
before calling on an unknown adapter.
The reaction's identifier where the platform gives it one
(Matrix), invisibly; TRUE where it does not (Slack).
## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") room <- chat_resolve(cl, "#general:example.org") id <- chat_send(cl, room, "hello") chat_react(cl, room, id, "+1") ## End(Not run)## Not run: # Requires a saved Matrix configuration and a joined room. cl <- chat_matrix(app = "mybot") room <- chat_resolve(cl, "#general:example.org") id <- chat_send(cl, room, "hello") chat_react(cl, room, id, "+1") ## End(Not run)
The record chat_poll returns in $reactions on
adapters whose chat_capabilities()$reaction_events is TRUE.
chat_reaction(id, channel, sender, target, key, ts, self = NULL, raw = NULL)chat_reaction(id, channel, sender, target, key, ts, self = NULL, raw = NULL)
id |
The reaction's own identifier, or NULL where the platform
does not give it one. Not the identifier of the message it is
attached to – see |
channel |
Channel/room identifier (character). |
sender |
Sender identifier (character). |
target |
Identifier of the message being reacted to (character). |
key |
The reaction itself (character): an emoji on Matrix, a short name on Slack. |
ts |
POSIXct timestamp, or |
self |
Logical: did this client place the reaction? A consumer that reacts to acknowledge needs this to avoid answering its own acknowledgement. NULL when the adapter cannot tell. |
raw |
The adapter's platform-native payload. |
Deliberately not a chat_message. A reaction has a target
and no body, and the message record has a body and no target;
thread is the closest slot and it means something else, so
folding one into the other would make every consumer disambiguate by
inspecting fields.
A list with class chat_reaction.
chat_reaction("r1", "general", "alice", target = "m1", key = "+1", ts = as.POSIXct("2026-01-01", tz = "UTC"))chat_reaction("r1", "general", "alice", target = "m1", key = "+1", ts = as.POSIXct("2026-01-01", tz = "UTC"))
Forces the re-authentication that adapters otherwise perform on demand. The refreshed credentials stay inside the client.
chat_relogin(client, ...)chat_relogin(client, ...)
client |
A |
... |
Adapter-specific options. |
The default throws rather than returning quietly. "I could not refresh" and "there was nothing to refresh" look identical to a caller that gets FALSE, and the first means the next call will fail with a stale token.
TRUE, invisibly.
## Not run: # Requires a saved Matrix configuration with login credentials. cl <- chat_matrix(app = "mybot") chat_relogin(cl) ## End(Not run)## Not run: # Requires a saved Matrix configuration with login credentials. cl <- chat_matrix(app = "mybot") chat_relogin(cl) ## End(Not run)
Resolve a human channel name to its identifier
chat_resolve(client, name, ...)chat_resolve(client, name, ...)
client |
A |
name |
Channel name, alias, or identifier. |
... |
Adapter-specific options. |
The adapter-native channel identifier (character).
chat_resolve(chat_loopback(), "general")chat_resolve(chat_loopback(), "general")
Send a message through a chat client
chat_send( client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, kind = "message", notify = TRUE, rich = NULL, ... )chat_send( client, channel, text, markup = c("plain", "markdown"), thread = NULL, reply_to = NULL, identity = NULL, files = NULL, kind = "message", notify = TRUE, rich = NULL, ... )
client |
A |
channel |
Channel/room identifier (adapter-native or resolved
via |
text |
Message text. |
markup |
|
thread |
Thread identifier to post into, or NULL. |
reply_to |
Message id being replied to, or NULL. |
identity |
Optional per-message identity override
( |
files |
Character vector of file paths to attach, or NULL. |
kind |
Message kind; |
notify |
Logical; FALSE requests a silent delivery where supported. |
rich |
Adapter-native markup for the platforms that accept it,
or NULL. Matrix takes an HTML fragment and sends it as
Ignored rather than refused where unsupported, on
|
... |
Adapter-specific options. |
Character vector of the message ids this call created, in the order they were sent, invisibly. Usually length one. A platform that splits a send into several events returns one id per event: the Matrix adapter sends each attachment as its own event, so a send with files returns the attachment ids followed by the text id. Callers that track their own traffic by id must handle every element, or an unclaimed event reads as somebody else's message.
cl <- chat_loopback() id <- chat_send(cl, "general", "hello", markup = "plain") chat_send(cl, "general", "a reply", thread = id) chat_poll(cl)$messagescl <- chat_loopback() id <- chat_send(cl, "general", "hello", markup = "plain") chat_send(cl, "general", "a reply", thread = id) chat_poll(cl)$messages
The account's own display name, as everyone in every channel sees it
until it is changed again. Distinct from chat_send's
identity argument, which decorates a single message on
platforms that allow it.
chat_set_identity(client, display, ...)chat_set_identity(client, display, ...)
client |
A |
display |
New display name. |
... |
Adapter-specific options. |
Owning this matters beyond tidiness. On Matrix the rename is an authenticated call that can rotate the access token underneath the caller, and a consumer that made that call itself had to notice the rotation and get the new token back into its client – usually via whatever file both of them happened to share. Behind the contract the rotation lands in the client that performed it, and nothing outside has to know it happened.
TRUE if the identity was changed, invisibly.
## Not run: # Requires a saved Matrix configuration and account credentials. cl <- chat_matrix(app = "mybot") chat_set_identity(cl, "Example Bot") ## End(Not run)## Not run: # Requires a saved Matrix configuration and account credentials. cl <- chat_matrix(app = "mybot") chat_set_identity(cl, "Example Bot") ## End(Not run)
Attaches a typed, durable piece of metadata to a channel, readable
by every client in it and replaced by the next write to the same
type and state_key. On Matrix this is a state event;
most platforms have no equivalent, which is why it is
capability-gated: check chat_capabilities()$set_state.
chat_set_state(client, channel, type, content, state_key = "", ...)chat_set_state(client, channel, type, content, state_key = "", ...)
client |
A |
channel |
Channel/room identifier. |
type |
Character. Namespaced event type, e.g.
|
content |
Named list. The state content. A write replaces the
whole content for its |
state_key |
Character. Sub-key within the type. Most state is keyed by the empty string, the default. |
... |
Adapter-specific options. |
The default method throws, on chat_react's reasoning:
a state write that silently did nothing leaves the caller believing
a marker is set that no reader will ever see.
The state event's identifier where the platform gives one
(Matrix), invisibly; TRUE where it does not.
cl <- chat_loopback() chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning")) chat_get_state(cl, "general", "m.room.topic")cl <- chat_loopback() chat_set_state(cl, "general", "m.room.topic", list(topic = "Planning")) chat_get_state(cl, "general", "m.room.topic")
Requires the suggested slackr package and a bot token.
Channel names are normalized to bare names (no #), matching
what slackr's channel translation accepts.
chat_slack( channels = character(), token = Sys.getenv("SLACK_TOKEN"), user_token = Sys.getenv("SLACK_USER_TOKEN"), username = NULL, .history = NULL, .post = NULL, .react = NULL, .api = NULL )chat_slack( channels = character(), token = Sys.getenv("SLACK_TOKEN"), user_token = Sys.getenv("SLACK_USER_TOKEN"), username = NULL, .history = NULL, .post = NULL, .react = NULL, .api = NULL )
channels |
Character vector of channels to poll. |
token |
Bot token; defaults to the |
user_token |
User token ( |
username |
Default display-name override for sends, or NULL (default) to post as the bot's own identity. |
.history |
Testing seam: replacement for
|
.post |
Testing seam: replacement for
|
.react |
Testing seam: replacement for
|
.api |
Testing seam: replacement for
|
Sends explicitly suppress slackr's SLACK_USERNAME /
SLACK_ICON_EMOJI environment defaults: a plain
chat_send() posts as the bot's own identity, and authorship
is only overridden through username here or
chat_send(identity =) (both need the chat:write.customize
scope).
user_token is a different kind of authorship than
identity: identity/username relabel a bot's own
post with a cosmetic name and icon, while a user token
(xoxp-..., obtained via a Slack app's User Token Scopes
rather than its Bot Token Scopes) authenticates as an actual
workspace member, so chat_send(..., as_user = TRUE) and
chat_whoami(..., as_user = TRUE) post and resolve identity as
that member – Slack shows their real name and photo, not a bot
profile. Optional: leave unset if you only ever post as the bot.
A post made as_user = TRUE is the member's own message in
every respect, including to this client's own chat_poll:
Slack messages carry no self, so nothing distinguishes it from
something the member typed, and a consumer that replies to the
member's traffic will reply to it.
A chat_client of class chat_slack.
## Not run: # Requires slackr, SLACK_TOKEN, and a channel the token can access. cl <- chat_slack(channels = "C0123456789") chat_send(cl, "C0123456789", "hello") ## End(Not run)## Not run: # Requires slackr, SLACK_TOKEN, and a channel the token can access. cl <- chat_slack(channels = "C0123456789") chat_send(cl, "C0123456789", "hello") ## End(Not run)
Requires the suggested httr package and a bot token from
BotFather, or a telegram::TGBot that carries one.
chat_telegram( token = Sys.getenv("TELEGRAM_BOT_TOKEN"), timeout = 30L, api_url = "https://api.telegram.org", bot = NULL, .api = NULL, .download = NULL )chat_telegram( token = Sys.getenv("TELEGRAM_BOT_TOKEN"), timeout = 30L, api_url = "https://api.telegram.org", bot = NULL, .api = NULL, .download = NULL )
token |
Bot token; defaults to the |
timeout |
Long-poll wait in seconds, used by
|
api_url |
Base URL of the Bot API. The default is Telegram's;
a local Bot API server takes its own. Ignored when |
bot |
A |
.api |
Testing seam: replacement for the HTTP layer, a
|
.download |
Testing seam: replacement for the file fetch, a
|
Channels are chat identifiers as Telegram reports them – a
positive number for a private chat, a negative one for a group or
channel – always as character. chat_resolve turns a
public @@username into one.
The first poll returns whatever updates Telegram is still holding
for the bot (it keeps them for 24 hours). That is the mail that
arrived while the bot was down rather than channel history, so it
comes out as ordinary traffic. Passing the returned cursor back as
since confirms it; Telegram re-sends anything unconfirmed.
A chat_client of class chat_telegram.
## Not run: # Requires httr, TELEGRAM_BOT_TOKEN, and access to the target chat. cl <- chat_telegram() chat_whoami(cl) chat_send(cl, "@example_channel", "hello") ## End(Not run)## Not run: # Requires httr, TELEGRAM_BOT_TOKEN, and access to the target chat. cl <- chat_telegram() chat_whoami(cl) chat_send(cl, "@example_channel", "hello") ## End(Not run)
Capability-gated: the default method is a no-op so adapters without typing indicators need not implement it.
chat_typing(client, channel, on = TRUE, ...) ## S3 method for class 'chat_matrix' chat_typing(client, channel, on = TRUE, timeout = 30, ...)chat_typing(client, channel, on = TRUE, ...) ## S3 method for class 'chat_matrix' chat_typing(client, channel, on = TRUE, timeout = 30, ...)
client |
A |
channel |
Channel/room identifier. |
on |
Logical. |
... |
Adapter-specific options. |
timeout |
Seconds the typing indicator should stand before the
homeserver clears it, for |
TRUE if the signal was sent, FALSE otherwise, invisibly.
cl <- chat_loopback() chat_typing(cl, "general") # FALSE: loopback has no typing indicatorcl <- chat_loopback() chat_typing(cl, "general") # FALSE: loopback has no typing indicator
The client's own account, as the transport identifies it.
chat_whoami(client, ...)chat_whoami(client, ...)
client |
A |
... |
Adapter-specific options. |
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.
chat_whoami(chat_loopback())chat_whoami(chat_loopback())