Matrix: knocking, reporting, filling a space, and sending a sticker
Four things Element does that moho could only watch happen. **Knocking.** A room whose join rule says "ask first" was a dead end here: the join failed with the server's refusal and there was nothing else to try. Now it is asked, with a reason - which is all the people inside have to decide by. No buffer is made for it, because a knock is not a join and a row in the list would say it was; the answer arrives as an invitation, which the invite list already shows. **Reporting.** The other answers to somebody behaving badly act on the person: ignore them, or leave. This is the one that reaches whoever runs the homeserver, and it needs no power in the room, which is the point of it. **Spaces.** `m.space.child` has been read since spaces were supported and never written, so a space made here stayed empty for ever and one made elsewhere could be looked at and not rearranged. Both directions now, with the child told about its parent where this account may say so - and allowed to fail where it may not, since adding your own room to somebody else's space is a thing you can do without power in the space. **Stickers.** Matrix has no sticker service: a sticker is an `m.sticker` event, and where the images come from is a convention - MSC2545 packs, in the account's own data and in the state of the rooms that share one. Both are read, and only the images a pack calls stickers are offered: sending an emoticon as a sticker posts a 20-pixel image as a message of its own. **And a place**, as a pin rather than live sharing, which is a different feature with different promises attached. `/location` takes what people actually paste - two numbers, a `geo:` URI, an OpenStreetMap or Google link - and refuses what is not a place rather than saying it aloud in the room. Closes #123, #124, #125, #126. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+215
-3
@@ -31,6 +31,7 @@ pub mod polls;
|
||||
pub mod calls;
|
||||
pub mod protocol;
|
||||
pub mod roomstate;
|
||||
pub mod stickers;
|
||||
pub mod rooms;
|
||||
pub mod verification;
|
||||
|
||||
@@ -392,6 +393,18 @@ async fn register_room(
|
||||
|
||||
state.runtime.set_matrix_room_encrypted(state, &buffer.id, rooms::is_encrypted(&event_refs));
|
||||
|
||||
// The packs this room shares with everybody in it. Keyed by state key
|
||||
// because a room may carry several, and named after the room where the
|
||||
// pack itself gives no name.
|
||||
for event in event_refs.iter().filter(|e| e["type"].as_str() == Some("im.ponies.room_emotes")) {
|
||||
state.runtime.set_matrix_sticker_pack(
|
||||
account_id,
|
||||
&format!("{room_id}|{}", event["state_key"].as_str().unwrap_or("")),
|
||||
&info.name,
|
||||
&event["content"],
|
||||
);
|
||||
}
|
||||
|
||||
roomstate::process_state_events(state, account_id, room_id, homeserver_url, access_token, &event_refs).await;
|
||||
// No presence data available at bootstrap time (that's /sync-only
|
||||
// - there's no bulk "current presence for all these users"
|
||||
@@ -437,6 +450,12 @@ async fn process_sync_response(state: &AppState, account_id: &str, own_user_id:
|
||||
if event["type"].as_str() == Some("m.ignored_user_list") {
|
||||
apply_ignored_users(state, account_id, &event["content"]);
|
||||
}
|
||||
// The account's own sticker pack, which travels with it between
|
||||
// clients - see backend/matrix/stickers.rs for the two places a
|
||||
// pack lives.
|
||||
if event["type"].as_str() == Some("im.ponies.user_emotes") {
|
||||
state.runtime.set_matrix_sticker_pack(account_id, "", "your stickers", &event["content"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1306,6 +1325,104 @@ pub async fn join_room(state: &AppState, account_id: &str, room_id_or_alias: &st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends one of the account's stickers.
|
||||
///
|
||||
/// Its own event type rather than a message with an image in it, which is what
|
||||
/// makes a sticker a sticker: clients draw it without a filename, without a
|
||||
/// download button, and inline at its own size. moho has read them since
|
||||
/// stickers were supported and could send none.
|
||||
pub async fn send_sticker(state: &AppState, account_id: &str, buffer_id: &str, mxc: &str, body: &str) -> Result<()> {
|
||||
let sticker = stickers::Sticker {
|
||||
name: body.to_string(),
|
||||
pack: String::new(),
|
||||
mxc: mxc.to_string(),
|
||||
body: body.to_string(),
|
||||
};
|
||||
send_typed_event(state, account_id, buffer_id, protocol::EVENT_STICKER, stickers::sticker_event(&sticker)).await
|
||||
}
|
||||
|
||||
/// Sends a place.
|
||||
///
|
||||
/// A pin rather than live location sharing, which is a different feature with
|
||||
/// a different set of promises attached - this says "here is somewhere",
|
||||
/// once, and stops being true about nothing when the window closes.
|
||||
pub async fn send_location(state: &AppState, account_id: &str, buffer_id: &str, place: &str, label: &str) -> Result<()> {
|
||||
let Some((latitude, longitude)) = stickers::parse_place(place) else {
|
||||
anyhow::bail!("that does not look like a place - try \"51.5, -0.12\" or a map link");
|
||||
};
|
||||
send_typed_event(
|
||||
state,
|
||||
account_id,
|
||||
buffer_id,
|
||||
protocol::EVENT_ROOM_MESSAGE,
|
||||
stickers::location_event(latitude, longitude, label),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Asks to be let in, where a room is asked rather than entered.
|
||||
///
|
||||
/// A knock room is one whose join rule says "ask first": joining it outright
|
||||
/// is refused, and the way in is to knock and wait for somebody inside to
|
||||
/// answer with an invitation. Without this a room like that was simply a dead
|
||||
/// end here - the join failed with the server's refusal and there was nothing
|
||||
/// else to try.
|
||||
///
|
||||
/// The reason travels with the knock and is what the people inside see, so it
|
||||
/// is worth writing: "asking to join" with no name attached is what most
|
||||
/// knocks look like, and most of those are ignored.
|
||||
pub async fn knock_room(state: &AppState, account_id: &str, room_id_or_alias: &str, via: &[String], reason: &str) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let encoded = url::form_urlencoded::byte_serialize(room_id_or_alias.trim().as_bytes()).collect::<String>();
|
||||
let mut url = format!("{base}/_matrix/client/v3/knock/{encoded}");
|
||||
// Same routing problem a join by room id has: the id names the room and
|
||||
// not who has it.
|
||||
for (i, server) in via.iter().filter(|s| !s.trim().is_empty()).enumerate() {
|
||||
url.push(if i == 0 { '?' } else { '&' });
|
||||
url.push_str("server_name=");
|
||||
url.push_str(&url::form_urlencoded::byte_serialize(server.trim().as_bytes()).collect::<String>());
|
||||
}
|
||||
let body = if reason.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({ "reason": reason.trim() })
|
||||
};
|
||||
http::post_json(&url, Some(&account.access_token), body).await.context("knocking on that room")?;
|
||||
// Deliberately no buffer: a knock is not a join, and putting the room in
|
||||
// the list would say you are in somewhere you have only asked about. The
|
||||
// answer arrives as an invitation, which the invite list already shows.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reports a message to the people who run the homeserver.
|
||||
///
|
||||
/// The other answers to somebody behaving badly act on the person - ignoring
|
||||
/// them, or leaving. This is the one that reaches whoever can actually do
|
||||
/// something about it, and it is the only one a server's moderators can act
|
||||
/// on. It needs no power in the room: reporting is a thing anybody may do,
|
||||
/// which is the point of it.
|
||||
pub async fn report_message(state: &AppState, account_id: &str, buffer_id: &str, event_id: &str, reason: &str) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known Matrix room for this buffer")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/report/{}",
|
||||
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>(),
|
||||
url::form_urlencoded::byte_serialize(event_id.as_bytes()).collect::<String>()
|
||||
);
|
||||
// The score is a severity from -100 to 0 that no server this client has
|
||||
// met does anything with, and a number nobody chose is worse than no
|
||||
// number: the reason is what a moderator reads.
|
||||
let body = if reason.trim().is_empty() {
|
||||
serde_json::json!({})
|
||||
} else {
|
||||
serde_json::json!({ "reason": reason.trim() })
|
||||
};
|
||||
http::post_json(&url, Some(&account.access_token), body).await.context("reporting that message")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Searches the homeserver's own copy of the conversation.
|
||||
///
|
||||
/// The local scrollback is this window's copy of what it happened to be
|
||||
@@ -1529,6 +1646,12 @@ pub async fn search_public_rooms(
|
||||
// published alias needs to be joined at all.
|
||||
"via": named(&server),
|
||||
"joined": joined.contains(&room_id),
|
||||
// How to get in. Most rooms in a directory are "public" and
|
||||
// are simply joined; a "knock" room has to be asked, and a
|
||||
// "restricted" one is open to members of a space this account
|
||||
// may not be in. A client that shows one Join button for all
|
||||
// three offers a button that fails for two of them.
|
||||
"joinRule": room["join_rule"].as_str().unwrap_or("public"),
|
||||
}));
|
||||
}
|
||||
// What this server actually contributed, after the rooms every other
|
||||
@@ -2811,17 +2934,75 @@ pub async fn set_room_state(
|
||||
event_type: &str,
|
||||
content: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let room_id = state.runtime.get_matrix_room(buffer_id).context("no known Matrix room for this buffer")?;
|
||||
put_room_state(state, account_id, &room_id, event_type, "", content).await
|
||||
}
|
||||
|
||||
/// One state event in a room, by type and key.
|
||||
///
|
||||
/// The key is the half `set_room_state` above never had: a name or a topic is
|
||||
/// the room's only one of its kind and needs none, but the events that make a
|
||||
/// space a space are keyed by the room they are about - one `m.space.child`
|
||||
/// per child - and without a key every child written would overwrite the last.
|
||||
pub async fn put_room_state(
|
||||
state: &AppState,
|
||||
account_id: &str,
|
||||
room_id: &str,
|
||||
event_type: &str,
|
||||
state_key: &str,
|
||||
content: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
let url = format!(
|
||||
"{base}/_matrix/client/v3/rooms/{}/state/{event_type}/",
|
||||
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>()
|
||||
"{base}/_matrix/client/v3/rooms/{}/state/{event_type}/{}",
|
||||
url::form_urlencoded::byte_serialize(room_id.as_bytes()).collect::<String>(),
|
||||
url::form_urlencoded::byte_serialize(state_key.as_bytes()).collect::<String>()
|
||||
);
|
||||
http::put_json(&url, &account.access_token, content).await.context("setting room state")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Puts a room in a space, or takes it out of one.
|
||||
///
|
||||
/// A space is an ordinary room whose contents are `m.space.child` state
|
||||
/// events, one per child, keyed by the child's room id. moho has read those
|
||||
/// since spaces were supported and never written one, so a space made here
|
||||
/// stayed empty for ever and one made elsewhere could be looked at and not
|
||||
/// rearranged.
|
||||
///
|
||||
/// Removing sends empty content rather than deleting the event, because Matrix
|
||||
/// has no delete: an entry with nothing in it is how the protocol spells "no
|
||||
/// longer a child", and `rooms::space_children` already skips those.
|
||||
///
|
||||
/// The child is told about its parent as well, where this account may say so.
|
||||
/// That is what makes the relationship visible from the room rather than only
|
||||
/// from the space - and it is allowed to fail: setting `m.space.parent` needs
|
||||
/// power in the *child*, which somebody adding their own room to somebody
|
||||
/// else's space will not have.
|
||||
pub async fn set_space_child(state: &AppState, account_id: &str, space_id: &str, child_room_id: &str, child: bool) -> Result<()> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let via = account.user_id.split(':').nth(1).unwrap_or_default().to_string();
|
||||
let content = if child {
|
||||
// `via` is not decoration: without a server to route through, a client
|
||||
// that has never met this room cannot join it from the space listing.
|
||||
serde_json::json!({ "via": [via], "suggested": false })
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
put_room_state(state, account_id, space_id, "m.space.child", child_room_id, content).await?;
|
||||
|
||||
let parent = if child {
|
||||
serde_json::json!({ "via": [via], "canonical": true })
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
if let Err(e) = put_room_state(state, account_id, child_room_id, "m.space.parent", space_id, parent).await {
|
||||
tracing::debug!("matrix[{account_id}]: {child_room_id} keeps no parent for {space_id}: {e:#}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn open_dm(state: &AppState, account_id: &str, target_user_id: &str, target_display_name: &str) -> Result<String> {
|
||||
let account = state.accounts.get_matrix(account_id).context("account not connected")?;
|
||||
let base = account.homeserver_url.trim_end_matches('/');
|
||||
@@ -2901,6 +3082,16 @@ pub async fn send_message(
|
||||
// Matrix spells m.emote. Inbound emotes were already understood;
|
||||
// typing one here sent the literal text. "//" escapes a leading
|
||||
// slash, matching how the IRC backend reads the same box.
|
||||
// Somewhere, rather than something said. Handled here because it is
|
||||
// typed in the same box and is a message like any other once sent -
|
||||
// and refused rather than posted as text when what follows is not a
|
||||
// place, since "/location where are you" said out loud to a room is
|
||||
// not what anybody meant.
|
||||
if let Some(rest) = body.strip_prefix("/location ").or_else(|| body.strip_prefix("/place ")) {
|
||||
let (place, label) = stickers::split_place_and_label(rest)
|
||||
.unwrap_or_else(|| (rest.to_string(), String::new()));
|
||||
return send_location(state, account_id, buffer_id, &place, &label).await;
|
||||
}
|
||||
let (msgtype, body) = match body.strip_prefix('/') {
|
||||
Some(literal) if literal.starts_with('/') => ("m.text", literal),
|
||||
Some(rest) => match rest.strip_prefix("me ") {
|
||||
@@ -4470,6 +4661,27 @@ fn space_group_id(account_id: &str, room_id: &str) -> String {
|
||||
format!("{account_id}|space:{room_id}")
|
||||
}
|
||||
|
||||
/// The room behind a space's rail entry, back out of its group id.
|
||||
///
|
||||
/// The client names a space by the group id it was given, which is the only
|
||||
/// handle it has; the room id inside it is what the protocol wants.
|
||||
pub fn space_room_id(group_id: &str) -> Option<String> {
|
||||
group_id.split_once("|space:").map(|(_, room)| room.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod space_id_tests {
|
||||
use super::{space_group_id, space_room_id};
|
||||
|
||||
#[test]
|
||||
fn a_group_id_carries_its_room_id() {
|
||||
let group = space_group_id("matrix:@a:example.org", "!space:example.org");
|
||||
assert_eq!(space_room_id(&group).as_deref(), Some("!space:example.org"));
|
||||
// A Discord guild's rail entry is not a space and must not answer.
|
||||
assert_eq!(space_room_id("discord:123|guild:456"), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a joined Space into a rail entry and files its rooms under it.
|
||||
///
|
||||
/// Spaces and their rooms arrive in whatever order joined_rooms happens to
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Sticker packs, and the places Matrix keeps them.
|
||||
//!
|
||||
//! Matrix has no sticker service. A sticker is an `m.sticker` event carrying
|
||||
//! an image, and where the images *come from* is a convention rather than a
|
||||
//! specification: MSC2545 image packs, which is what Element's own picker
|
||||
//! ends up reading and what every pack bot writes.
|
||||
//!
|
||||
//! A pack lives in one of two places, and both are read here because people
|
||||
//! have them in both:
|
||||
//!
|
||||
//! - `im.ponies.user_emotes` in the account's own data, which is the pack
|
||||
//! somebody assembled for themselves and carries between clients.
|
||||
//! - `im.ponies.room_emotes` in a room's state, which is the pack a room
|
||||
//! shares with everybody in it - keyed by state key, so a room may have
|
||||
//! several.
|
||||
//!
|
||||
//! An image in a pack says what it is for: `usage: ["sticker"]`, `["emoticon"]`
|
||||
//! or neither, which means both. Only stickers are offered here; sending an
|
||||
//! emoticon as a sticker posts a 20-pixel image as a message of its own, which
|
||||
//! is not what anybody meant by it.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// One image out of a pack, in the shape the picker draws and sends.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Sticker {
|
||||
/// The shortcode, which is also what a client with no images shows.
|
||||
pub name: String,
|
||||
/// The pack it came from, for grouping in the picker.
|
||||
pub pack: String,
|
||||
/// The image itself, still as an mxc URI - resolving it needs the
|
||||
/// account's token, so that happens where the token is.
|
||||
pub mxc: String,
|
||||
/// What it is called in a client that cannot show it, and the fallback
|
||||
/// body of the event that sends it.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// Whether this image is a sticker, as its pack describes it.
|
||||
///
|
||||
/// Absent usage means "both", per the convention: a pack that says nothing is
|
||||
/// a pack of images usable either way, and dropping those would empty most of
|
||||
/// the packs people actually have.
|
||||
fn is_sticker(image: &Value, pack_usage: &Value) -> bool {
|
||||
let usage = image.get("usage").filter(|u| u.is_array()).unwrap_or(pack_usage);
|
||||
match usage.as_array() {
|
||||
None => true,
|
||||
Some(list) if list.is_empty() => true,
|
||||
Some(list) => list.iter().any(|u| u.as_str() == Some("sticker")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads one `im.ponies.*` pack into the stickers it offers.
|
||||
///
|
||||
/// Tolerant on purpose: these are written by half a dozen different bots and
|
||||
/// clients, and one malformed image in a pack should cost that image rather
|
||||
/// than the pack.
|
||||
pub fn read_pack(content: &Value, fallback_name: &str) -> Vec<Sticker> {
|
||||
let pack_name = content["pack"]["display_name"]
|
||||
.as_str()
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.unwrap_or(fallback_name)
|
||||
.to_string();
|
||||
let pack_usage = content["pack"]["usage"].clone();
|
||||
let Some(images) = content["images"].as_object() else {
|
||||
return Vec::new();
|
||||
};
|
||||
images
|
||||
.iter()
|
||||
.filter(|(_, image)| is_sticker(image, &pack_usage))
|
||||
.filter_map(|(shortcode, image)| {
|
||||
let mxc = image["url"].as_str()?;
|
||||
if !mxc.starts_with("mxc://") {
|
||||
return None;
|
||||
}
|
||||
Some(Sticker {
|
||||
name: shortcode.clone(),
|
||||
pack: pack_name.clone(),
|
||||
mxc: mxc.to_string(),
|
||||
body: image["body"]
|
||||
.as_str()
|
||||
.filter(|b| !b.trim().is_empty())
|
||||
.unwrap_or(shortcode)
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The content of an `m.sticker` event for one of these.
|
||||
///
|
||||
/// `info` is left to the caller: a sticker's dimensions are what a client
|
||||
/// draws it at, and this module does not fetch the image to measure it.
|
||||
pub fn sticker_event(sticker: &Sticker) -> Value {
|
||||
serde_json::json!({
|
||||
"body": sticker.body,
|
||||
"url": sticker.mxc,
|
||||
"info": {},
|
||||
})
|
||||
}
|
||||
|
||||
/// A place, as `m.location` spells one.
|
||||
///
|
||||
/// The `geo:` URI is the machine-readable half and the body is what a client
|
||||
/// with no map shows - which is most of them, this one included, so the body
|
||||
/// is written to be read rather than to be parsed.
|
||||
pub fn location_event(latitude: f64, longitude: f64, label: &str) -> Value {
|
||||
let geo = format!("geo:{latitude},{longitude}");
|
||||
let body = if label.trim().is_empty() {
|
||||
format!("{latitude}, {longitude}")
|
||||
} else {
|
||||
format!("{}, {latitude}, {longitude}", label.trim())
|
||||
};
|
||||
serde_json::json!({
|
||||
"msgtype": "m.location",
|
||||
"body": body,
|
||||
"geo_uri": geo,
|
||||
"org.matrix.msc3488.location": { "uri": geo, "description": label.trim() },
|
||||
"org.matrix.msc3488.asset": { "type": "m.pin" },
|
||||
})
|
||||
}
|
||||
|
||||
/// Coordinates out of whatever somebody pasted.
|
||||
///
|
||||
/// People do not type `geo:` URIs. They paste a map link, or they type two
|
||||
/// numbers with a comma between them, and both of those are what this
|
||||
/// accepts - along with the `geo:` form, since a location received here can
|
||||
/// then be sent on.
|
||||
pub fn parse_place(text: &str) -> Option<(f64, f64)> {
|
||||
let text = text.trim();
|
||||
// A pair of numbers, however spaced: "51.5, -0.12".
|
||||
let plain = text.trim_start_matches("geo:");
|
||||
if let Some((lat, lon)) = plain.split_once(',') {
|
||||
if let (Ok(lat), Ok(lon)) = (lat.trim().parse::<f64>(), lon.trim().split(';').next().unwrap_or("").trim().parse::<f64>()) {
|
||||
if valid(lat, lon) {
|
||||
return Some((lat, lon));
|
||||
}
|
||||
}
|
||||
}
|
||||
// A map link, in the two shapes people paste. Read by their own layout
|
||||
// rather than by scanning for any two numbers: OpenStreetMap puts the
|
||||
// zoom level first, and "the first two numbers in this URL" reads that
|
||||
// zoom as a latitude.
|
||||
let pair = |rest: &str, separators: [char; 2], skip: usize| -> Option<(f64, f64)> {
|
||||
let mut parts = rest.split(separators).skip(skip);
|
||||
let lat = parts.next()?.trim().parse::<f64>().ok()?;
|
||||
let lon = parts.next()?.trim().parse::<f64>().ok()?;
|
||||
valid(lat, lon).then_some((lat, lon))
|
||||
};
|
||||
// openstreetmap.org/#map=15/51.5/-0.12
|
||||
if let Some(rest) = text.split("#map=").nth(1) {
|
||||
if let Some(found) = pair(rest, ['/', '&'], 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
// google.com/maps/@51.5,-0.12,15z
|
||||
if let Some(rest) = text.split('@').nth(1) {
|
||||
if let Some(found) = pair(rest, [',', '/'], 0) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
// Anything with the place in a query parameter.
|
||||
for marker in ["?q=", "&q=", "query=", "?mlat=", "ll="] {
|
||||
if let Some(rest) = text.split(marker).nth(1) {
|
||||
if let Some(found) = pair(rest, [',', '&'], 0) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Splits "51.5, -0.12 the pub" into the place and what it is called.
|
||||
///
|
||||
/// The place is however many words still parse as one, which is the only way
|
||||
/// to tell the two apart: coordinates are written with a space after the comma
|
||||
/// as often as not, so "the first word" is half a place, and "everything" is a
|
||||
/// place with a name stuck to it. Longest wins, and what is left is the name.
|
||||
pub fn split_place_and_label(text: &str) -> Option<(String, String)> {
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
let mut best = 0;
|
||||
for take in 1..=words.len() {
|
||||
if parse_place(&words[..take].join(" ")).is_some() {
|
||||
best = take;
|
||||
}
|
||||
}
|
||||
(best > 0).then(|| (words[..best].join(" "), words[best..].join(" ")))
|
||||
}
|
||||
|
||||
/// Somewhere on Earth, rather than two numbers that happened to parse.
|
||||
fn valid(latitude: f64, longitude: f64) -> bool {
|
||||
(-90.0..=90.0).contains(&latitude) && (-180.0..=180.0).contains(&longitude)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pack() -> Value {
|
||||
serde_json::json!({
|
||||
"pack": { "display_name": "Cats" },
|
||||
"images": {
|
||||
"sleepy": { "url": "mxc://example.org/a", "usage": ["sticker"] },
|
||||
"wave": { "url": "mxc://example.org/b", "body": "waving cat" },
|
||||
"tiny": { "url": "mxc://example.org/c", "usage": ["emoticon"] },
|
||||
"broken": { "body": "no url at all" },
|
||||
"elsewhere": { "url": "https://example.org/not-matrix.png" }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_offers_its_stickers_and_not_its_emoticons() {
|
||||
let mut read = read_pack(&pack(), "fallback");
|
||||
read.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
let names: Vec<&str> = read.iter().map(|s| s.name.as_str()).collect();
|
||||
// "wave" says nothing about usage, which means both - dropping those
|
||||
// would empty most real packs.
|
||||
assert_eq!(names, vec!["sleepy", "wave"]);
|
||||
assert_eq!(read[0].pack, "Cats");
|
||||
assert_eq!(read[1].body, "waving cat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_with_no_name_borrows_the_one_it_was_filed_under() {
|
||||
let content = serde_json::json!({ "images": { "a": { "url": "mxc://example.org/a" } } });
|
||||
assert_eq!(read_pack(&content, "this room").first().unwrap().pack, "this room");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_at_all_is_not_an_error() {
|
||||
assert!(read_pack(&serde_json::json!({}), "x").is_empty());
|
||||
assert!(read_pack(&serde_json::json!({ "images": [] }), "x").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_place_is_read_from_what_people_actually_paste() {
|
||||
assert_eq!(parse_place("51.5074, -0.1278"), Some((51.5074, -0.1278)));
|
||||
assert_eq!(parse_place("geo:51.5074,-0.1278;u=35"), Some((51.5074, -0.1278)));
|
||||
assert_eq!(
|
||||
parse_place("https://www.openstreetmap.org/#map=15/51.5074/-0.1278"),
|
||||
Some((51.5074, -0.1278))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_place("https://www.google.com/maps/@51.5074,-0.1278,15z"),
|
||||
Some((51.5074, -0.1278))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_numbers_that_are_not_a_place_are_refused() {
|
||||
// A latitude past the pole is somebody's phone number, not a place.
|
||||
assert_eq!(parse_place("910, 20"), None);
|
||||
assert_eq!(parse_place("hello"), None);
|
||||
assert_eq!(parse_place(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_place_and_its_name_come_apart() {
|
||||
// The comma-space form is how people write coordinates, so the place
|
||||
// is two words and the name is the rest.
|
||||
assert_eq!(
|
||||
split_place_and_label("51.5074, -0.1278 the pub"),
|
||||
Some(("51.5074, -0.1278".to_string(), "the pub".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_place_and_label("51.5074,-0.1278"),
|
||||
Some(("51.5074,-0.1278".to_string(), String::new()))
|
||||
);
|
||||
assert_eq!(
|
||||
split_place_and_label("https://www.openstreetmap.org/#map=15/51.5074/-0.1278 home"),
|
||||
Some(("https://www.openstreetmap.org/#map=15/51.5074/-0.1278".to_string(), "home".to_string()))
|
||||
);
|
||||
assert_eq!(split_place_and_label("where are you"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_location_event_carries_both_halves() {
|
||||
let event = location_event(51.5074, -0.1278, "the pub");
|
||||
assert_eq!(event["geo_uri"], "geo:51.5074,-0.1278");
|
||||
assert_eq!(event["msgtype"], "m.location");
|
||||
// The body is what a client with no map shows, so the label leads.
|
||||
assert_eq!(event["body"], "the pub, 51.5074, -0.1278");
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub struct Builtin {
|
||||
|
||||
const IRC: &[&str] = &["irc"];
|
||||
const IRC_AND_MATRIX: &[&str] = &["irc", "matrix"];
|
||||
const MATRIX: &[&str] = &["matrix"];
|
||||
/// The ones that are only text, and so work wherever this daemon does the
|
||||
/// sending itself.
|
||||
///
|
||||
@@ -50,6 +51,12 @@ pub const BUILTINS: &[Builtin] = &[
|
||||
Builtin { name: "away", usage: "[reason]", description: "Mark yourself away", services: IRC },
|
||||
Builtin { name: "back", usage: "", description: "Stop being away", services: IRC },
|
||||
Builtin { name: "whois", usage: "<nick>", description: "Look somebody up", services: IRC },
|
||||
Builtin {
|
||||
name: "location",
|
||||
usage: "<lat,lon or a map link> [name]",
|
||||
description: "Send a place",
|
||||
services: MATRIX,
|
||||
},
|
||||
Builtin { name: "notify", usage: "[nick]", description: "Watch for somebody arriving, or list who is watched", services: IRC },
|
||||
Builtin { name: "unnotify", usage: "<nick>", description: "Stop watching for somebody", services: IRC },
|
||||
Builtin { name: "whowas", usage: "<nick>", description: "Look up somebody who has left", services: IRC },
|
||||
|
||||
@@ -3495,6 +3495,122 @@ pub async fn dispatch(
|
||||
|
||||
// A room's own name and topic. Two state events with one shape, which
|
||||
// is why they share a method rather than having one each.
|
||||
// The account's own stickers, from the packs it carries and the ones
|
||||
// its rooms share.
|
||||
"listMatrixStickers" => {
|
||||
let Some(buffer_id) = p_str_opt(params, "bufferId") else {
|
||||
return (None, Some("listMatrixStickers requires \"bufferId\"".to_string()));
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
let account = match state.accounts.get_matrix(&buffer.account_id) {
|
||||
Some(a) => a,
|
||||
None => return (Some(serde_json::json!([])), None),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for sticker in state.runtime.matrix_stickers(&buffer.account_id) {
|
||||
// Resolved here, where the token is: an mxc URI names media on
|
||||
// a homeserver and is not a URL anything can load.
|
||||
let path = backend::matrix::cached_media_path(&account.homeserver_url, &account.access_token, &sticker.mxc, "").await;
|
||||
out.push(serde_json::json!({
|
||||
"name": sticker.name,
|
||||
"pack": sticker.pack,
|
||||
"mxc": sticker.mxc,
|
||||
"body": sticker.body,
|
||||
"url": path,
|
||||
}));
|
||||
}
|
||||
(Some(serde_json::json!(out)), None)
|
||||
}
|
||||
|
||||
"sendMatrixSticker" => {
|
||||
let (buffer_id, mxc) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "mxc")) {
|
||||
(Some(b), Some(m)) => (b, m),
|
||||
_ => return (None, Some("sendMatrixSticker requires \"bufferId\" and \"mxc\"".to_string())),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
match backend::matrix::send_sticker(state, &buffer.account_id, buffer_id, mxc, p_str(params, "body", "sticker")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Somewhere, as a pin. Live location sharing is a different feature
|
||||
// with different promises and is deliberately not this.
|
||||
"sendMatrixLocation" => {
|
||||
let (buffer_id, place) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "place")) {
|
||||
(Some(b), Some(p)) => (b, p),
|
||||
_ => return (None, Some("sendMatrixLocation requires \"bufferId\" and \"place\"".to_string())),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
match backend::matrix::send_location(state, &buffer.account_id, buffer_id, place, p_str(params, "label", "")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Asking to be let into a room that is asked rather than entered.
|
||||
"knockMatrixRoom" => {
|
||||
let (account_id, room) = match (p_str_opt(params, "accountId"), p_str_opt(params, "roomIdOrAlias")) {
|
||||
(Some(a), Some(r)) => (a, r),
|
||||
_ => return (None, Some("knockMatrixRoom requires \"accountId\" and \"roomIdOrAlias\"".to_string())),
|
||||
};
|
||||
let via: Vec<String> = params
|
||||
.get("via")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
|
||||
.unwrap_or_default();
|
||||
match backend::matrix::knock_room(state, account_id, room, &via, p_str(params, "reason", "")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Telling whoever runs the server about a message. Needs no power in
|
||||
// the room, which is the whole point of it.
|
||||
"reportMatrixMessage" => {
|
||||
let (buffer_id, message_id) = match (p_str_opt(params, "bufferId"), p_str_opt(params, "messageId")) {
|
||||
(Some(b), Some(m)) => (b, m),
|
||||
_ => return (None, Some("reportMatrixMessage requires \"bufferId\" and \"messageId\"".to_string())),
|
||||
};
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
match backend::matrix::report_message(state, &buffer.account_id, buffer_id, message_id, p_str(params, "reason", "")).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Putting a room in a space, or taking it out of one.
|
||||
"setMatrixSpaceChild" => {
|
||||
let (space_group_id, buffer_id) = match (p_str_opt(params, "spaceId"), p_str_opt(params, "bufferId")) {
|
||||
(Some(s), Some(b)) => (s, b),
|
||||
_ => return (None, Some("setMatrixSpaceChild requires \"spaceId\" and \"bufferId\"".to_string())),
|
||||
};
|
||||
let child = params.get("child").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let Some(buffer) = state.runtime.get_buffer(buffer_id) else {
|
||||
return (None, Some("no such buffer".to_string()));
|
||||
};
|
||||
let Some(child_room) = state.runtime.get_matrix_room(buffer_id) else {
|
||||
return (None, Some("that conversation is not a Matrix room".to_string()));
|
||||
};
|
||||
// The client names the space by its group id, which is what the
|
||||
// rail and the room list both hold; the room id is inside it.
|
||||
let Some(space_room) = crate::backend::matrix::space_room_id(space_group_id) else {
|
||||
return (None, Some("that is not a Matrix space".to_string()));
|
||||
};
|
||||
match backend::matrix::set_space_child(state, &buffer.account_id, &space_room, &child_room, child).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
"setMatrixRoomState" => {
|
||||
let (account_id, buffer_id) = match (p_str_opt(params, "accountId"), p_str_opt(params, "bufferId")) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
|
||||
@@ -432,6 +432,13 @@ pub struct Runtime {
|
||||
/// refuses that line. Asking for history from a server that never granted
|
||||
/// it is a command it will answer with an error in the server tab.
|
||||
irc_caps: Mutex<HashMap<String, std::collections::HashSet<String>>>,
|
||||
/// Sticker packs, per account: pack key -> the images it offers.
|
||||
///
|
||||
/// One flat table rather than per-room, because a picker offers everything
|
||||
/// this account has: a pack shared by one room is still a pack, and
|
||||
/// hiding it in every other room would be a picker that changes its mind
|
||||
/// depending on where you opened it.
|
||||
matrix_stickers: Mutex<HashMap<String, HashMap<String, Vec<crate::backend::matrix::stickers::Sticker>>>>,
|
||||
/// Accounts whose server has answered a MONITOR - see irc_monitors.
|
||||
irc_monitor: Mutex<std::collections::HashSet<String>>,
|
||||
/// Matrix-specific: buffer id -> room id. Matrix buffer *names* are
|
||||
@@ -828,6 +835,7 @@ impl Runtime {
|
||||
irc_channel_lists: Mutex::new(HashMap::new()),
|
||||
irc_caps: Mutex::new(HashMap::new()),
|
||||
irc_monitor: Mutex::new(std::collections::HashSet::new()),
|
||||
matrix_stickers: Mutex::new(HashMap::new()),
|
||||
matrix_rooms: Mutex::new(HashMap::new()),
|
||||
matrix_room_names: Mutex::new(HashMap::new()),
|
||||
matrix_space_parents: Mutex::new(HashMap::new()),
|
||||
@@ -2911,6 +2919,31 @@ impl Runtime {
|
||||
self.irc_caps.lock().unwrap().remove(account_id);
|
||||
}
|
||||
|
||||
/// Records one sticker pack, replacing whatever was under that key.
|
||||
///
|
||||
/// An empty pack removes the key rather than leaving an empty group in
|
||||
/// the picker: a pack somebody emptied should stop being offered.
|
||||
pub fn set_matrix_sticker_pack(&self, account_id: &str, key: &str, fallback_name: &str, content: &serde_json::Value) {
|
||||
let stickers = crate::backend::matrix::stickers::read_pack(content, fallback_name);
|
||||
let mut all = self.matrix_stickers.lock().unwrap();
|
||||
let packs = all.entry(account_id.to_string()).or_default();
|
||||
if stickers.is_empty() {
|
||||
packs.remove(key);
|
||||
} else {
|
||||
packs.insert(key.to_string(), stickers);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every sticker this account has, pack by pack.
|
||||
pub fn matrix_stickers(&self, account_id: &str) -> Vec<crate::backend::matrix::stickers::Sticker> {
|
||||
self.matrix_stickers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(account_id)
|
||||
.map(|packs| packs.values().flatten().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether this server answers MONITOR, learnt from it doing so.
|
||||
///
|
||||
/// Not negotiated and not advertised in a way worth parsing: a server that
|
||||
|
||||
Reference in New Issue
Block a user