// Matrix Client-Server API adapter.
//
// Mirrors Hermes `plugins/platforms/matrix/adapter.py`: messages are sent with
// `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}`, replies
// use `m.relates_to`, and inbound traffic is consumed from `/sync` timelines.
// Access is via `MATRIX_ACCESS_TOKEN`, or `MATRIX_PASSWORD` login when no
// token is configured.
///|
/// Cached access tokens keyed by `homeserver|user`.
let matrix_token_cache : Map[String, String] = Map([])
///|
/// Cached `@user:server` ids for typing indicators.
let matrix_user_cache : Map[String, String] = Map([])
///|
/// Homeserver base URL (`MATRIX_HOMESERVER`), without a trailing slash.
pub fn matrix_homeserver(cfg : PlatformConfig) -> String {
let url = cfg.extra_str("homeserver", "")
if url.has_suffix("/") {
url[:url.length() - 1].to_owned()
} else {
url
}
}
///|
/// `/_matrix/client/v3` root.
pub fn matrix_api_base(cfg : PlatformConfig) -> String {
"\{matrix_homeserver(cfg)}/_matrix/client/v3"
}
///|
/// `PUT …/send/m.room.message/{txnId}` payload.
pub fn matrix_send_payload(body : String, reply_to : String?) -> Json {
let payload = json_obj([
("msgtype", Json::string("m.text")),
("body", Json::string(body)),
])
match reply_to {
Some(mid) =>
if payload is Object(m) {
m.set(
"m.relates_to",
json_obj([
("m.in_reply_to", json_obj([("event_id", Json::string(mid))])),
]),
)
}
None => ()
}
payload
}
///|
/// `m.replace` edit payload.
pub fn matrix_edit_payload(new_text : String, message_id : String) -> Json {
json_obj([
("msgtype", Json::string("m.text")),
("body", Json::string("* \{new_text}")),
(
"m.new_content",
json_obj([
("msgtype", Json::string("m.text")),
("body", Json::string(new_text)),
]),
),
(
"m.relates_to",
json_obj([
("rel_type", Json::string("m.replace")),
("event_id", Json::string(message_id)),
]),
),
])
}
///|
/// `m.annotation` reaction payload.
pub fn matrix_reaction_payload(message_id : String, emoji : String) -> Json {
json_obj([
(
"m.relates_to",
json_obj([
("rel_type", Json::string("m.annotation")),
("event_id", Json::string(message_id)),
("key", Json::string(emoji)),
]),
),
])
}
///|
/// `Authorization: Bearer` headers for Matrix.
pub fn matrix_headers(token : String) -> Map[String, String] {
bearer_headers(token)
}
///|
/// Log in with `MATRIX_USER_ID` + `MATRIX_PASSWORD` and cache the token.
async fn matrix_login(cfg : PlatformConfig) -> String? {
let user = cfg.extra_str("user_id", "")
let password = cfg.extra_str("password", "")
if user.is_empty() || password.is_empty() || matrix_homeserver(cfg).is_empty() {
return None
}
let body = json_obj([
("type", Json::string("m.login.password")),
(
"identifier",
json_obj([
("type", Json::string("m.id.user")),
("user", Json::string(user)),
]),
),
("password", Json::string(password)),
])
let url = "\{matrix_api_base(cfg)}/login"
match post_json(url, body, json_headers()) {
Err(_) => None
Ok(j) =>
match json_field_string(j, "access_token") {
Some(token) => {
matrix_token_cache.set("\{matrix_homeserver(cfg)}|\{user}", token)
match json_field_string(j, "user_id") {
Some(uid) => matrix_user_cache.set(matrix_homeserver(cfg), uid)
None => ()
}
Some(token)
}
None => None
}
}
}
///|
/// Resolve the access token, logging in when only a password is configured.
pub async fn matrix_resolve_token(cfg : PlatformConfig) -> String? {
match cfg.token_of() {
Some(t) => Some(t)
None => {
let user = cfg.extra_str("user_id", "")
let key = "\{matrix_homeserver(cfg)}|\{user}"
match matrix_token_cache.get(key) {
Some(t) => Some(t)
None => matrix_login(cfg)
}
}
}
}
///|
/// The bot's own `@user:server` id — from the login/typing cache or
/// `/account/whoami`, falling back to the configured `user_id`.
pub async fn matrix_self_user(cfg : PlatformConfig, token : String) -> String {
let key = matrix_homeserver(cfg)
match matrix_user_cache.get(key) {
Some(u) => u
None => {
let url = "\{matrix_api_base(cfg)}/account/whoami"
match get_json(url, matrix_headers(token)) {
Ok(j) =>
match json_field_string(j, "user_id") {
Some(u) => {
matrix_user_cache.set(key, u)
u
}
None => cfg.extra_str("user_id", "")
}
Err(_) => cfg.extra_str("user_id", "")
}
}
}
}
///|
/// `GET /_matrix/client/v3/sync`, long-polling for up to ``timeout_ms``.
pub async fn matrix_sync(
cfg : PlatformConfig,
token : String,
since : String,
timeout_ms : Int32,
) -> Result[Json, ApiError] {
let mut url = "\{matrix_api_base(cfg)}/sync?timeout=\{timeout_ms}"
if !since.is_empty() {
url = "\{url}&since=\{url_quote(since)}"
}
get_json(url, matrix_headers(token))
}
///|
/// One Matrix call; PUT/POST JSON and map errors.
async fn matrix_call(
cfg : PlatformConfig,
token : String,
meth : String,
path : String,
payload : Json,
) -> SendResult {
let url = "\{matrix_api_base(cfg)}\{path}"
let res = match meth {
"PUT" => put_json(url, payload, matrix_headers(token))
"POST" => post_json(url, payload, matrix_headers(token))
_ => get_json(url, matrix_headers(token))
}
match res {
Err(e) => send_failure("matrix", e)
Ok(j) => send_ok(json_field_string(j, "event_id"), j)
}
}
///|
/// Send ``content`` to a room, chunked at Matrix's 60000-char limit.
pub async fn matrix_send(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to? : String,
thread_id? : String,
) -> SendResult {
ignore(thread_id)
matrix_send_with_token(cfg, chat_id, content, reply_to)
}
///|
async fn matrix_send_with_token(
cfg : PlatformConfig,
chat_id : String,
content : String,
reply_to : String?,
) -> SendResult {
match matrix_resolve_token(cfg) {
None => missing_config(Matrix, "MATRIX_ACCESS_TOKEN")
Some(token) =>
if content.is_empty() {
return SendResult::ok()
} else {
let room = url_quote(chat_id)
let mut first = true
let mut last = SendResult::ok()
let mut first_id : String? = None
for chunk in chunk_text(content, Platform::max_message_length(Matrix)) {
let reply = if first { reply_to } else { None }
let txn = random_hex(16)
let r = matrix_call(
cfg,
token,
"PUT",
"/rooms/\{room}/send/m.room.message/\{txn}",
matrix_send_payload(chunk, reply),
)
first = false
if !r.success {
return r
}
if first_id is None {
first_id = r.message_id
}
last = r
}
{ ..last, message_id: first_id, }
}
}
}
///|
/// Typing indicator (`PUT /rooms/{room}/typing/{userId}`).
pub async fn matrix_typing(cfg : PlatformConfig, chat_id : String) -> Unit {
match matrix_resolve_token(cfg) {
None => ()
Some(token) => {
let key = matrix_homeserver(cfg)
let user = match matrix_user_cache.get(key) {
Some(u) => u
None => {
let url = "\{matrix_api_base(cfg)}/account/whoami"
match get_json(url, matrix_headers(token)) {
Ok(j) =>
match json_field_string(j, "user_id") {
Some(u) => {
matrix_user_cache.set(key, u)
u
}
None => ""
}
Err(_) => ""
}
}
}
if user.is_empty() {
return
}
let payload = json_obj([
("typing", Json::boolean(true)),
("timeout", Json::number(30000.0)),
])
ignore(
matrix_call(
cfg,
token,
"PUT",
"/rooms/\{url_quote(chat_id)}/typing/\{url_quote(user)}",
payload,
),
)
}
}
}
///|
/// Edit a message with `m.replace`.
pub async fn matrix_edit(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
content : String,
) -> SendResult {
match matrix_resolve_token(cfg) {
None => missing_config(Matrix, "MATRIX_ACCESS_TOKEN")
Some(token) =>
matrix_call(
cfg,
token,
"PUT",
"/rooms/\{url_quote(chat_id)}/send/m.room.message/\{random_hex(16)}",
matrix_edit_payload(content, message_id),
)
}
}
///|
/// React with `m.annotation`.
pub async fn matrix_react(
cfg : PlatformConfig,
chat_id : String,
message_id : String,
emoji : String,
) -> SendResult {
match matrix_resolve_token(cfg) {
None => missing_config(Matrix, "MATRIX_ACCESS_TOKEN")
Some(token) =>
matrix_call(
cfg,
token,
"PUT",
"/rooms/\{url_quote(chat_id)}/send/m.reaction/\{random_hex(16)}",
matrix_reaction_payload(message_id, emoji),
)
}
}
///|
/// Room name via `GET /rooms/{roomId}/state/m.room.name`.
pub async fn matrix_get_chat(
cfg : PlatformConfig,
chat_id : String,
) -> ChatInfo? {
match matrix_resolve_token(cfg) {
None => None
Some(token) => {
let url = "\{matrix_api_base(cfg)}/rooms/\{url_quote(chat_id)}/state/m.room.name"
match get_json(url, matrix_headers(token)) {
Err(_) => None
Ok(j) =>
Some({
name: json_field_string(j, "name").unwrap_or(chat_id),
chat_type: "room",
chat_id,
})
}
}
}
}
///|
/// Parse a `/sync` response (or a bare timeline) into message events.
/// The caller's own user id (``self_user``) is echoed out.
pub fn matrix_parse_sync(
body : String,
self_user? : String,
) -> Array[MessageEvent] {
let out : Array[MessageEvent] = []
let j = @json.parse(body) catch { _ => return out }
let rooms = json_field_obj(j, "rooms").unwrap_or(Json::null())
let joined = json_field_obj(rooms, "join").unwrap_or(Json::null())
match joined {
Object(m) =>
for room_id, room in m {
let timeline = json_field_obj(room, "timeline").unwrap_or(Json::null())
for ev in json_field_array(timeline, "events").unwrap_or([]) {
match matrix_parse_event(room_id, ev, self_user?) {
Some(e) => out.push(e)
None => ()
}
}
}
_ => ()
}
out
}
///|
/// Convert one room event into a `MessageEvent`.
pub fn matrix_parse_event(
room_id : String,
ev : Json,
self_user? : String,
) -> MessageEvent? {
if json_field_string(ev, "type").unwrap_or("") != "m.room.message" {
return None
}
let sender = json_field_string(ev, "sender").unwrap_or("")
match self_user {
Some(u) => if u == sender { return None }
None => ()
}
let content = json_field_obj(ev, "content").unwrap_or(Json::null())
let text = json_field_string(content, "body").unwrap_or("")
if text.is_empty() {
return None
}
let reply = match json_field_obj(content, "m.relates_to") {
Some(r) => json_field_string(r, "event_id")
None => None
}
let mtype = if text.has_prefix("/") { Command } else { Text }
Some(
MessageEvent::new(
text,
mtype,
Matrix,
room_id,
user_id=sender,
message_id?=json_field_string(ev, "event_id"),
reply_to_message_id?=reply,
media=[],
timestamp=now_ms(),
),
)
}