atagen

macltok

public
Star0
Watch0

Commit fa51490b6ae9

tarball

atagen <boss@atagen.co>2026-10-24 08:47 UTC

10 files changed+1148-48
packs: send stickers, edit msc2545 packs and clone them onto your homeserver

Every edit reads the server's copy and rewrites only the keys it owns, because the store lags a sync behind and malice doesn't model info, attribution or unknown fields. Edits run in order per pack on the mutation scheduler, but rehosting stays off it and only queues for the final write, so a long clone_pack doesn't block other edits to the same pack. Emptied packs are still the only ones dropped from image_packs, which lets a fresh pack show up before it has images.
Modified file: src/engine/media.rs+13 additions
@@ -353,6 +353,19 @@ fn too_large(message: &'static str) -> Error {
353353 Error::new(ErrorKind::MediaTooLarge, message)
354354 }
355355
356+pub(crate) async fn download_file(
357+ client: &Client,
358+ source: MediaSource,
359+ mimetype: Option<String>,
360+ max_bytes: usize,
361+) -> Result<Media, Error> {
362+ let request = MediaRequestParameters {
363+ source,
364+ format: MediaFormat::File,
365+ };
366+ fetch(client.clone(), request, mimetype, max_bytes).await
367+}
368+
356369 async fn fetch(
357370 client: Client,
358371 request: MediaRequestParameters,
Modified file: src/engine/mod.rs+82 additions−2 deletions
@@ -2,6 +2,7 @@ mod commands;
22 pub(crate) mod executor;
33 mod media;
44 mod mutations;
5+mod packs;
56 mod prepare;
67 mod room;
78 mod session;
@@ -35,13 +36,15 @@ use self::{
3536 commands::Job,
3637 executor::{EngineBoxFuture, TaskSet},
3738 mutations::{Mutation, MutationJob},
39+ packs::PackEdit,
3840 room::{ActorNote, ActorRequest, RoomActor},
3941 timeline::{SessionContext, Supervisor},
4042 };
4143 use crate::{
4244 AccountCommand, ClientHandle, Command, CommandOutput, ConnectionState, EncryptionCommand,
43- EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand,
44- MediaCommand, NotificationCommand, RoomCommand, Termination, TimelineCommand,
45+ EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, ImagePackSource,
46+ LifecycleCommand, MediaCommand, NotificationCommand, PackCommand, RoomCommand, Termination,
47+ TimelineCommand,
4548 normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},
4649 transport::{DriverPort, Request, Responder, transport_pair},
4750 };
@@ -744,6 +747,7 @@ impl Engine {
744747 Command::Notification(command) => {
745748 enqueue(&self.notifications, Job { command, reply });
746749 }
750+ Command::Pack(command) => self.pack(command, reply),
747751 }
748752 None
749753 }
@@ -767,6 +771,82 @@ impl Engine {
767771 });
768772 }
769773
774+ fn pack(&mut self, command: PackCommand, reply: Responder) {
775+ let edit = |pack, edit| Mutation::Pack { pack, edit };
776+ let mutation = match command {
777+ PackCommand::CreateRoomPack {
778+ room_id,
779+ state_key,
780+ display_name,
781+ } => edit(
782+ ImagePackSource::Room { room_id, state_key },
783+ PackEdit::Create { display_name },
784+ ),
785+ PackCommand::Delete(pack) => edit(pack, PackEdit::Delete),
786+ PackCommand::SetMeta {
787+ pack,
788+ display_name,
789+ avatar,
790+ usage,
791+ } => edit(
792+ pack,
793+ PackEdit::Meta {
794+ display_name,
795+ avatar,
796+ usage,
797+ },
798+ ),
799+ PackCommand::PutImage {
800+ pack,
801+ shortcode,
802+ mxc,
803+ body,
804+ usage,
805+ info,
806+ } => edit(
807+ pack,
808+ PackEdit::Put {
809+ shortcode,
810+ mxc,
811+ body,
812+ usage,
813+ info,
814+ },
815+ ),
816+ PackCommand::RenameImage { pack, from, to } => {
817+ edit(pack, PackEdit::Rename { from, to })
818+ }
819+ PackCommand::RemoveImage { pack, shortcode } => {
820+ edit(pack, PackEdit::Remove { shortcode })
821+ }
822+ PackCommand::SetEnabledEverywhere {
823+ room_id,
824+ state_key,
825+ enabled,
826+ } => Mutation::PackEnabledEverywhere {
827+ room_id,
828+ state_key,
829+ enabled,
830+ },
831+ command => {
832+ let client = self.client.clone();
833+ let mutations = self.mutations.clone();
834+ let max_bytes = self.config.max_media_bytes;
835+ self.tasks.spawn(TaskContext::Command("pack"), async move {
836+ reply.send(packs::run_detached(client, mutations, max_bytes, command).await);
837+ });
838+ return;
839+ }
840+ };
841+ enqueue(
842+ &self.mutations,
843+ Job {
844+ command: mutation,
845+ reply,
846+ },
847+ );
848+ }
849+
770850 fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {
771851 let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {
772852 return reply.done(Err(Error::room_unknown()));
Modified file: src/engine/mutations.rs+35 additions−2 deletions
@@ -30,9 +30,12 @@ use tokio::sync::mpsc;
3030
3131 use super::commands::{Job, guarded};
3232 use super::executor::TaskSet;
33+use super::packs::{self, PackEdit};
3334 use crate::{
34- CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,
35- RoomVisibility, normalise::stored_space_order, space_order,
35+ CommandOutput, CreateRoomOptions, Error, ErrorKind, ImagePackSource, PowerLevelChanges,
36+ RoomCommand, RoomPreset, RoomVisibility,
37+ normalise::{ENABLED_ROOMS, USER_PACK, stored_space_order},
38+ space_order,
3639 };
3740
3841 /// state changes that must not race each other. same room, same alias, same dm
@@ -48,6 +51,15 @@ pub(super) enum Mutation {
4851 ignored: bool,
4952 },
5053 ReorderSpaces(Vec<OwnedRoomId>),
54+ Pack {
55+ pack: ImagePackSource,
56+ edit: PackEdit,
57+ },
58+ PackEnabledEverywhere {
59+ room_id: OwnedRoomId,
60+ state_key: String,
61+ enabled: bool,
62+ },
5163 }
5264
5365 pub(super) type MutationJob = Job<Mutation>;
@@ -132,6 +144,17 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
132144 Mutation::ReorderSpaces(_) => {
133145 return Some(Key::AccountData(space_order::EVENT_TYPE.to_owned()));
134146 }
147+ Mutation::Pack {
148+ pack: ImagePackSource::User,
149+ ..
150+ } => return Some(Key::AccountData(USER_PACK.to_owned())),
151+ Mutation::Pack {
152+ pack: ImagePackSource::Room { room_id, .. },
153+ ..
154+ } => room_id,
155+ Mutation::PackEnabledEverywhere { .. } => {
156+ return Some(Key::AccountData(ENABLED_ROOMS.to_owned()));
157+ }
135158 Mutation::Room(RoomCommand::Join { room, .. }) => {
136159 return Some(match OwnedRoomId::try_from(room.clone()) {
137160 Ok(room_id) => Key::Room(room_id),
@@ -216,6 +239,16 @@ async fn execute(
216239 Mutation::ReorderSpaces(order) => {
217240 return reorder_spaces(client, &fresh.orders, order).await.map(done);
218241 }
242+ Mutation::Pack { pack, edit } => return packs::edit_pack(client, &pack, edit).await,
243+ Mutation::PackEnabledEverywhere {
244+ room_id,
245+ state_key,
246+ enabled,
247+ } => {
248+ return packs::set_enabled_everywhere(client, room_id, state_key, enabled)
249+ .await
250+ .map(done);
251+ }
219252 Mutation::Room(command) => command,
220253 };
221254 let fresh_dms = &fresh.dms;
Added file: src/engine/packs.rs+585 additions
@@ -0,0 +1,585 @@
1+use futures_util::{StreamExt, stream};
2+use matrix_sdk::{
3+ Client,
4+ ruma::{
5+ api::{client::state::get_state_event_for_key, error::ErrorKind as ApiErrorKind},
6+ events::{GlobalAccountDataEventType, StateEventType, room::MediaSource},
7+ serde::{JsonObject, Raw},
8+ },
9+};
10+use ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId};
11+use serde_json::{Value as JsonValue, json};
12+use tokio::sync::mpsc;
13+
14+use super::{
15+ commands::{self, Job},
16+ enqueue, media,
17+ mutations::{Mutation, MutationJob},
18+};
19+use crate::{
20+ CommandOutput, Error, ErrorKind, ImagePackSource, PackCommand, PackImageInfo, PackUsage, Patch,
21+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, resolve_pack_id, stored_pack},
22+ transport::Responder,
23+};
24+
25+/// read-modify-writes of one pack, which run in order per pack
26+pub(super) enum PackEdit {
27+ Create {
28+ display_name: String,
29+ },
30+ Delete,
31+ Meta {
32+ display_name: Patch<String>,
33+ avatar: Patch<OwnedMxcUri>,
34+ usage: Patch<Vec<PackUsage>>,
35+ },
36+ Put {
37+ shortcode: String,
38+ mxc: OwnedMxcUri,
39+ body: Option<String>,
40+ usage: Option<Vec<PackUsage>>,
41+ info: Option<PackImageInfo>,
42+ },
43+ Rename {
44+ from: String,
45+ to: String,
46+ },
47+ Remove {
48+ shortcode: String,
49+ },
50+ /// copies already on our homeserver. `create` is the pack meta for a room pack that doesn't exist yet
51+ Insert {
52+ images: Vec<(String, JsonObject)>,
53+ create: Option<JsonObject>,
54+ },
55+}
56+
57+pub(super) async fn edit_pack(
58+ client: &Client,
59+ pack: &ImagePackSource,
60+ edit: PackEdit,
61+) -> Result<CommandOutput, Error> {
62+ let current = fetch_pack(client, pack).await?;
63+ let mut content = current.clone().unwrap_or_default();
64+ let mut output = CommandOutput::Done;
65+ match edit {
66+ PackEdit::Create { display_name } => {
67+ if !content.is_empty() {
68+ return Err(Error::new(
69+ ErrorKind::Invalid,
70+ "that state key already holds a pack",
71+ ));
72+ }
73+ content = object(json!({ "pack": { "display_name": display_name }, "images": {} }));
74+ }
75+ PackEdit::Delete => content.clear(),
76+ PackEdit::Meta {
77+ display_name,
78+ avatar,
79+ usage,
80+ } => {
81+ let meta = child(&mut content, "pack");
82+ patch(meta, "display_name", display_name, JsonValue::String);
83+ patch(meta, "avatar_url", avatar, |uri| uri.to_string().into());
84+ patch(meta, "usage", usage, usage_json);
85+ }
86+ PackEdit::Put {
87+ shortcode,
88+ mxc,
89+ body,
90+ usage,
91+ info,
92+ } => {
93+ let images = child(&mut content, "images");
94+ let mut image = match images.remove(&shortcode) {
95+ Some(JsonValue::Object(image)) => image,
96+ _ => JsonObject::new(),
97+ };
98+ let same_media = image.get("url").and_then(JsonValue::as_str) == Some(mxc.as_str());
99+ image.insert("url".to_owned(), mxc.to_string().into());
100+ if let Some(body) = body {
101+ image.insert("body".to_owned(), body.into());
102+ }
103+ if let Some(usage) = usage {
104+ image.insert("usage".to_owned(), usage_json(usage));
105+ }
106+ match info {
107+ Some(info) => {
108+ image.insert("info".to_owned(), info_json(info));
109+ }
110+ None if !same_media => {
111+ image.remove("info");
112+ }
113+ None => {}
114+ }
115+ images.insert(shortcode, image.into());
116+ }
117+ PackEdit::Rename { from, to } => {
118+ let images = child(&mut content, "images");
119+ if images.contains_key(&to) {
120+ return Err(Error::new(ErrorKind::Invalid, "that shortcode is taken"));
121+ }
122+ let image = images
123+ .remove(&from)
124+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no image has that shortcode"))?;
125+ images.insert(to, image);
126+ }
127+ PackEdit::Remove { shortcode } => {
128+ child(&mut content, "images").remove(&shortcode);
129+ }
130+ PackEdit::Insert { images, create } => {
131+ if content.is_empty() {
132+ match (create, pack) {
133+ (Some(meta), _) => {
134+ content.insert("pack".to_owned(), meta.into());
135+ }
136+ (None, ImagePackSource::Room { .. }) => {
137+ return Err(Error::new(
138+ ErrorKind::Invalid,
139+ "there's no pack there to copy into",
140+ ));
141+ }
142+ (None, ImagePackSource::User) => {}
143+ }
144+ }
145+ let target = child(&mut content, "images");
146+ let shortcodes = images
147+ .into_iter()
148+ .map(|(shortcode, image)| {
149+ let free = free_shortcode(target, &shortcode);
150+ target.insert(free.clone(), image.into());
151+ free
152+ })
153+ .collect();
154+ output = CommandOutput::Cloned {
155+ shortcodes,
156+ skipped: Vec::new(),
157+ };
158+ }
159+ }
160+ if current.as_ref() != Some(&content) {
161+ write_pack(client, pack, content).await?;
162+ }
163+ Ok(output)
164+}
165+
166+pub(super) async fn set_enabled_everywhere(
167+ client: &Client,
168+ room_id: OwnedRoomId,
169+ state_key: String,
170+ enabled: bool,
171+) -> Result<(), Error> {
172+ let account = client.account();
173+ let current = account
174+ .fetch_account_data(GlobalAccountDataEventType::from(ENABLED_ROOMS))
175+ .await
176+ .map_err(|source| Error::from_sdk("reading enabled packs failed", source))?
177+ .map(|raw| parse(raw.json().get()))
178+ .transpose()?;
179+ let mut content = current.clone().unwrap_or_default();
180+ let rooms = child(&mut content, "rooms");
181+ if enabled {
182+ child(rooms, room_id.as_str()).insert(state_key, JsonObject::new().into());
183+ } else {
184+ let state_keys = child(rooms, room_id.as_str());
185+ state_keys.remove(&state_key);
186+ if state_keys.is_empty() {
187+ rooms.remove(room_id.as_str());
188+ }
189+ }
190+ if current.as_ref() == Some(&content) {
191+ return Ok(());
192+ }
193+ account
194+ .set_account_data_raw(ENABLED_ROOMS.into(), raw(&content))
195+ .await
196+ .map(drop)
197+ .map_err(|source| Error::from_sdk("writing enabled packs failed", source))
198+}
199+
200+/// the commands that download, upload or send, so they stay off the per-pack queue and only
201+/// take their turn for the final write
202+pub(super) async fn run_detached(
203+ client: Client,
204+ mutations: mpsc::Sender<MutationJob>,
205+ max_bytes: usize,
206+ command: PackCommand,
207+) -> Result<CommandOutput, Error> {
208+ match command {
209+ PackCommand::SendSticker {
210+ room_id,
211+ pack_id,
212+ shortcode,
213+ } => send_sticker(&client, room_id, &pack_id, &shortcode)
214+ .await
215+ .map(|()| CommandOutput::Done),
216+ PackCommand::Rehost(mxc) => rehost(&client, mxc, None, max_bytes)
217+ .await
218+ .map(CommandOutput::Mxc),
219+ PackCommand::CloneImage {
220+ from_pack,
221+ shortcode,
222+ to,
223+ } => {
224+ let image = pack_image(&client, &from_pack, &shortcode).await?;
225+ let image = rehost_image(&client, image, max_bytes).await?;
226+ insert(&mutations, to, vec![(shortcode, image)], None, Vec::new()).await
227+ }
228+ PackCommand::CloneSticker {
229+ room_id,
230+ event_id,
231+ to,
232+ shortcode,
233+ } => {
234+ let image = sticker_image(&client, room_id, event_id).await?;
235+ let image = rehost_image(&client, image, max_bytes).await?;
236+ insert(&mutations, to, vec![(shortcode, image)], None, Vec::new()).await
237+ }
238+ PackCommand::ClonePack {
239+ from_pack,
240+ to,
241+ display_name,
242+ } => clone_pack(&client, &mutations, max_bytes, &from_pack, to, display_name).await,
243+ _ => Err(Error::new(ErrorKind::Other, "pack command was misrouted")),
244+ }
245+}
246+
247+async fn clone_pack(
248+ client: &Client,
249+ mutations: &mpsc::Sender<MutationJob>,
250+ max_bytes: usize,
251+ from_pack: &str,
252+ to: ImagePackSource,
253+ display_name: Option<String>,
254+) -> Result<CommandOutput, Error> {
255+ let source = resolve_pack_id(client, from_pack)
256+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no pack has that id"))?;
257+ let content = stored_pack(client, &source)
258+ .await?
259+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that pack is empty"))?;
260+ let mut meta = match content.get("pack") {
261+ Some(JsonValue::Object(meta)) => meta.clone(),
262+ _ => JsonObject::new(),
263+ };
264+ let images: Vec<(String, JsonObject)> = match content.get("images") {
265+ Some(JsonValue::Object(images)) => images
266+ .iter()
267+ .filter_map(|(shortcode, image)| {
268+ let image = with_pack_usage(image.as_object()?.clone(), &meta);
269+ Some((shortcode.clone(), image))
270+ })
271+ .collect(),
272+ _ => Vec::new(),
273+ };
274+ let results: Vec<_> = stream::iter(images)
275+ .map(|(shortcode, image)| async move {
276+ match rehost_image(client, image, max_bytes).await {
277+ Ok(image) => Ok((shortcode, image)),
278+ Err(error) => {
279+ tracing::debug!(%error, shortcode, "skipping an image that wouldn't rehost");
280+ Err(shortcode)
281+ }
282+ }
283+ })
284+ .buffered(4)
285+ .collect()
286+ .await;
287+ let (copies, skipped): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);
288+ let copies = copies.into_iter().filter_map(Result::ok).collect();
289+ let skipped = skipped.into_iter().filter_map(Result::err).collect();
290+ let create = match to {
291+ ImagePackSource::User => None,
292+ ImagePackSource::Room { .. } => {
293+ if let Some(display_name) = display_name {
294+ meta.insert("display_name".to_owned(), display_name.into());
295+ }
296+ meta.remove("usage");
297+ if let Some(avatar) = meta.remove("avatar_url").and_then(|uri| mxc(&uri))
298+ && let Ok(avatar) = rehost(client, avatar, None, max_bytes).await
299+ {
300+ meta.insert("avatar_url".to_owned(), avatar.to_string().into());
301+ }
302+ Some(meta)
303+ }
304+ };
305+ insert(mutations, to, copies, create, skipped).await
306+}
307+
308+async fn insert(
309+ mutations: &mpsc::Sender<MutationJob>,
310+ pack: ImagePackSource,
311+ images: Vec<(String, JsonObject)>,
312+ create: Option<JsonObject>,
313+ skipped: Vec<String>,
314+) -> Result<CommandOutput, Error> {
315+ let (reply, answer) = Responder::channel();
316+ let command = Mutation::Pack {
317+ pack,
318+ edit: PackEdit::Insert { images, create },
319+ };
320+ enqueue(mutations, Job { command, reply });
321+ match answer.await? {
322+ CommandOutput::Cloned { shortcodes, .. } => Ok(CommandOutput::Cloned {
323+ shortcodes,
324+ skipped,
325+ }),
326+ output => Ok(output),
327+ }
328+}
329+
330+async fn send_sticker(
331+ client: &Client,
332+ room_id: OwnedRoomId,
333+ pack_id: &str,
334+ shortcode: &str,
335+) -> Result<(), Error> {
336+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
337+ let image = pack_image(client, pack_id, shortcode).await?;
338+ let url = image
339+ .get("url")
340+ .and_then(mxc)
341+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that image has no usable url"))?;
342+ let content = json!({
343+ "body": image.get("body").and_then(JsonValue::as_str).unwrap_or(shortcode),
344+ "url": url,
345+ "info": image.get("info").cloned().unwrap_or_else(|| json!({})),
346+ });
347+ room.send_queue()
348+ .send_raw(raw(&object(content)), "m.sticker".to_owned())
349+ .await
350+ .map(drop)
351+ .map_err(|source| {
352+ Error::with_source(ErrorKind::Store, "queueing the sticker failed", source)
353+ })
354+}
355+
356+/// the image with its pack's usage folded in, so it means the same once it leaves the pack
357+async fn pack_image(client: &Client, pack_id: &str, shortcode: &str) -> Result<JsonObject, Error> {
358+ let source = resolve_pack_id(client, pack_id)
359+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no pack has that id"))?;
360+ let content = stored_pack(client, &source).await?.unwrap_or_default();
361+ let image = content
362+ .get("images")
363+ .and_then(|images| images.get(shortcode))
364+ .and_then(JsonValue::as_object)
365+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no image has that shortcode"))?
366+ .clone();
367+ let meta = content
368+ .get("pack")
369+ .and_then(JsonValue::as_object)
370+ .cloned()
371+ .unwrap_or_default();
372+ Ok(with_pack_usage(image, &meta))
373+}
374+
375+async fn sticker_image(
376+ client: &Client,
377+ room_id: OwnedRoomId,
378+ event_id: OwnedEventId,
379+) -> Result<JsonObject, Error> {
380+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
381+ let event = room
382+ .load_or_fetch_event(&event_id, None)
383+ .await
384+ .map_err(|source| Error::from_sdk("loading the sticker failed", source))?;
385+ let raw = event.raw();
386+ if raw.get_field::<String>("type").ok().flatten().as_deref() != Some("m.sticker") {
387+ return Err(Error::new(ErrorKind::Invalid, "that event isn't a sticker"));
388+ }
389+ let content = raw
390+ .get_field::<JsonObject>("content")
391+ .ok()
392+ .flatten()
393+ .unwrap_or_default();
394+ let mut image = JsonObject::new();
395+ for key in ["url", "body", "info"] {
396+ if let Some(value) = content.get(key) {
397+ image.insert(key.to_owned(), value.clone());
398+ }
399+ }
400+ image.insert("usage".to_owned(), usage_json(vec![PackUsage::Sticker]));
401+ Ok(image)
402+}
403+
404+async fn rehost_image(
405+ client: &Client,
406+ mut image: JsonObject,
407+ max_bytes: usize,
408+) -> Result<JsonObject, Error> {
409+ let url = image
410+ .get("url")
411+ .and_then(mxc)
412+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that image has no usable url"))?;
413+ let mimetype = image
414+ .get("info")
415+ .and_then(|info| info.get("mimetype"))
416+ .and_then(JsonValue::as_str)
417+ .map(str::to_owned);
418+ let url = rehost(client, url, mimetype, max_bytes).await?;
419+ image.insert("url".to_owned(), url.to_string().into());
420+ Ok(image)
421+}
422+
423+pub(super) async fn rehost(
424+ client: &Client,
425+ mxc: OwnedMxcUri,
426+ mimetype: Option<String>,
427+ max_bytes: usize,
428+) -> Result<OwnedMxcUri, Error> {
429+ let homeserver = client.user_id().map(ruma::UserId::server_name);
430+ if mxc
431+ .server_name()
432+ .ok()
433+ .is_some_and(|server| Some(server) == homeserver)
434+ {
435+ return Ok(mxc);
436+ }
437+ let media = media::download_file(client, MediaSource::Plain(mxc), mimetype, max_bytes).await?;
438+ commands::upload_media(client, &media.mimetype, media.bytes.to_vec(), max_bytes).await
439+}
440+
441+/// the server's copy, since the store lags a sync behind and an edit made just before would be lost
442+async fn fetch_pack(client: &Client, pack: &ImagePackSource) -> Result<Option<JsonObject>, Error> {
443+ match pack {
444+ ImagePackSource::User => client
445+ .account()
446+ .fetch_account_data(GlobalAccountDataEventType::from(USER_PACK))
447+ .await
448+ .map_err(|source| Error::from_sdk("reading the pack failed", source))?
449+ .map(|raw| parse(raw.json().get()))
450+ .transpose(),
451+ ImagePackSource::Room { room_id, state_key } => {
452+ let request = get_state_event_for_key::v3::Request::new(
453+ room_id.clone(),
454+ StateEventType::from(ROOM_PACKS),
455+ state_key.clone(),
456+ );
457+ match client.send(request).await {
458+ Ok(response) => parse(response.event_or_content.get()).map(Some),
459+ Err(error) if error.client_api_error_kind() == Some(&ApiErrorKind::NotFound) => {
460+ Ok(None)
461+ }
462+ Err(source) => Err(Error::from_http("reading the pack failed", source)),
463+ }
464+ }
465+ }
466+}
467+
468+async fn write_pack(
469+ client: &Client,
470+ pack: &ImagePackSource,
471+ content: JsonObject,
472+) -> Result<(), Error> {
473+ let written = match pack {
474+ ImagePackSource::User => client
475+ .account()
476+ .set_account_data_raw(USER_PACK.into(), raw(&content))
477+ .await
478+ .map(drop),
479+ ImagePackSource::Room { room_id, state_key } => client
480+ .get_room(room_id)
481+ .ok_or_else(Error::room_unknown)?
482+ .send_state_event_raw(ROOM_PACKS, state_key, JsonValue::Object(content))
483+ .await
484+ .map(drop),
485+ };
486+ written.map_err(|source| Error::from_sdk("writing the pack failed", source))
487+}
488+
489+fn with_pack_usage(mut image: JsonObject, meta: &JsonObject) -> JsonObject {
490+ if !image.contains_key("usage")
491+ && let Some(usage) = meta.get("usage")
492+ {
493+ image.insert("usage".to_owned(), usage.clone());
494+ }
495+ image
496+}
497+
498+fn free_shortcode(images: &JsonObject, wanted: &str) -> String {
499+ if !images.contains_key(wanted) {
500+ return wanted.to_owned();
501+ }
502+ (2..)
503+ .map(|n| format!("{wanted}-{n}"))
504+ .find(|candidate| !images.contains_key(candidate))
505+ .expect("an unbounded range finds a free name")
506+}
507+
508+/// the object at `key`, made or replaced if it was missing or malformed
509+fn child<'a>(parent: &'a mut JsonObject, key: &str) -> &'a mut JsonObject {
510+ let slot = parent
511+ .entry(key.to_owned())
512+ .or_insert_with(|| JsonObject::new().into());
513+ if !slot.is_object() {
514+ *slot = JsonObject::new().into();
515+ }
516+ slot.as_object_mut().expect("just made it an object")
517+}
518+
519+fn patch<T>(
520+ meta: &mut JsonObject,
521+ key: &str,
522+ change: Patch<T>,
523+ to_json: impl FnOnce(T) -> JsonValue,
524+) {
525+ match change {
526+ Patch::Keep => {}
527+ Patch::Clear => {
528+ meta.remove(key);
529+ }
530+ Patch::Set(value) => {
531+ meta.insert(key.to_owned(), to_json(value));
532+ }
533+ }
534+}
535+
536+fn usage_json(usage: Vec<PackUsage>) -> JsonValue {
537+ usage
538+ .into_iter()
539+ .map(|usage| match usage {
540+ PackUsage::Emoticon => "emoticon",
541+ PackUsage::Sticker => "sticker",
542+ })
543+ .collect::<Vec<_>>()
544+ .into()
545+}
546+
547+fn info_json(info: PackImageInfo) -> JsonValue {
548+ let mut out = JsonObject::new();
549+ if let Some(mimetype) = info.mimetype {
550+ out.insert("mimetype".to_owned(), mimetype.into());
551+ }
552+ for (key, value) in [
553+ ("size", info.size_bytes),
554+ ("w", info.width),
555+ ("h", info.height),
556+ ] {
557+ if let Some(value) = value {
558+ out.insert(key.to_owned(), value.into());
559+ }
560+ }
561+ out.into()
562+}
563+
564+fn mxc(value: &JsonValue) -> Option<OwnedMxcUri> {
565+ let uri = OwnedMxcUri::from(value.as_str()?);
566+ uri.is_valid().then_some(uri)
567+}
568+
569+fn object(value: JsonValue) -> JsonObject {
570+ match value {
571+ JsonValue::Object(object) => object,
572+ _ => JsonObject::new(),
573+ }
574+}
575+
576+fn parse(json: &str) -> Result<JsonObject, Error> {
577+ serde_json::from_str(json)
578+ .map_err(|source| Error::with_source(ErrorKind::Server, "the pack is malformed", source))
579+}
580+
581+fn raw<T>(content: &JsonObject) -> Raw<T> {
582+ Raw::new(content)
583+ .expect("a json object always serialises")
584+ .cast_unchecked()
585+}
Modified file: src/lib.rs+10 additions−10 deletions
@@ -53,16 +53,16 @@ pub use model::{
5353 KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage, Media,
5454 MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,
5555 MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,
56- MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackImage,
57- PackUsage, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
58- PushNotificationRoom, PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState,
59- ReplyDetails, ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero,
60- RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings,
61- RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild,
62- Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,
63- TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,
64- UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
65- VerificationState, VerificationTarget,
56+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackCommand,
57+ PackImage, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile, PushNotification,
58+ PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
59+ Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,
60+ RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
61+ RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
62+ Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
63+ TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
64+ UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,
65+ VerificationFailure, VerificationState, VerificationTarget,
6666 };
6767 pub use ruma;
6868 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
Modified file: src/model.rs+120 additions
@@ -27,6 +27,7 @@ pub enum Command {
2727 Encryption(EncryptionCommand),
2828 Notification(NotificationCommand),
2929 Lifecycle(LifecycleCommand),
30+ Pack(PackCommand),
3031 }
3132
3233 #[derive(Debug)]
@@ -357,6 +358,114 @@ pub enum MediaCommand {
357358 Upload(AttachmentUpload),
358359 }
359360
361+/// msc2545 pack editing. edits read the server's copy and keep keys macltok doesn't model
362+#[derive(Clone, Debug, Eq, PartialEq)]
363+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
364+#[non_exhaustive]
365+pub enum PackCommand {
366+ /// body defaults to the shortcode
367+ SendSticker {
368+ room_id: OwnedRoomId,
369+ pack_id: String,
370+ shortcode: String,
371+ },
372+ /// fails if the state key already holds a pack
373+ CreateRoomPack {
374+ room_id: OwnedRoomId,
375+ state_key: String,
376+ display_name: String,
377+ },
378+ /// empties the content, which is all deleting a pack can mean
379+ Delete(ImagePackSource),
380+ SetMeta {
381+ pack: ImagePackSource,
382+ display_name: Patch<String>,
383+ avatar: Patch<OwnedMxcUri>,
384+ usage: Patch<Vec<PackUsage>>,
385+ },
386+ /// adds or replaces by shortcode. a replaced image keeps its info unless the url changed
387+ PutImage {
388+ pack: ImagePackSource,
389+ shortcode: String,
390+ mxc: OwnedMxcUri,
391+ body: Option<String>,
392+ usage: Option<Vec<PackUsage>>,
393+ info: Option<PackImageInfo>,
394+ },
395+ /// fails if `to` is taken
396+ RenameImage {
397+ pack: ImagePackSource,
398+ from: String,
399+ to: String,
400+ },
401+ RemoveImage {
402+ pack: ImagePackSource,
403+ shortcode: String,
404+ },
405+ SetEnabledEverywhere {
406+ room_id: OwnedRoomId,
407+ state_key: String,
408+ enabled: bool,
409+ },
410+ /// answers with `CommandOutput::Mxc`, the same uri when it already lives on your homeserver
411+ Rehost(OwnedMxcUri),
412+ /// the rest answer with `CommandOutput::Cloned`. clashes get `-2`, `-3` and so on
413+ CloneImage {
414+ from_pack: String,
415+ shortcode: String,
416+ to: ImagePackSource,
417+ },
418+ CloneSticker {
419+ room_id: OwnedRoomId,
420+ event_id: OwnedEventId,
421+ to: ImagePackSource,
422+ shortcode: String,
423+ },
424+ /// makes the room pack if its state key is free, else merges. images that can't be
425+ /// fetched are skipped and listed, the rest are written
426+ ClonePack {
427+ from_pack: String,
428+ to: ImagePackSource,
429+ display_name: Option<String>,
430+ },
431+}
432+
433+#[derive(Clone, Debug, Default, Eq, PartialEq)]
434+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
435+pub enum Patch<T> {
436+ #[default]
437+ Keep,
438+ Clear,
439+ Set(T),
440+}
441+
442+#[derive(Clone, Debug, Default, Eq, PartialEq)]
443+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
444+#[non_exhaustive]
445+pub struct PackImageInfo {
446+ pub mimetype: Option<String>,
447+ pub size_bytes: Option<u64>,
448+ pub width: Option<u64>,
449+ pub height: Option<u64>,
450+}
451+
452+impl PackImageInfo {
453+ #[must_use]
454+ pub fn new(
455+ mimetype: Option<String>,
456+ size_bytes: Option<u64>,
457+ width: Option<u64>,
458+ height: Option<u64>,
459+ ) -> Self {
460+ Self {
461+ mimetype,
462+ size_bytes,
463+ width,
464+ height,
465+ }
466+ }
467+}
468+
360469 #[derive(Clone, Debug, Eq, PartialEq)]
361470 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
362471 #[non_exhaustive]
@@ -511,6 +620,11 @@ pub enum CommandOutput {
511620 Done,
512621 Room(OwnedRoomId),
513622 Mxc(OwnedMxcUri),
623+ /// the shortcodes the copies ended up under, and the ones that couldn't be fetched
624+ Cloned {
625+ shortcodes: Vec<String>,
626+ skipped: Vec<String>,
627+ },
514628 }
515629
516630 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -858,6 +972,8 @@ pub struct RoomSummary {
858972 pub children: Vec<OwnedRoomId>,
859973 /// a space's msc3230 order. ordered spaces sort first by (order, room id), the rest by room id
860974 pub space_order: Option<String>,
975+ /// you can send im.ponies.room_emotes here, so you can make and edit its packs
976+ pub can_edit_packs: bool,
861977 }
862978
863979 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1737,6 +1853,10 @@ pub struct ImagePack {
17371853 pub attribution: Option<String>,
17381854 pub source: ImagePackSource,
17391855 pub images: Vec<PackImage>,
1856+ /// your own pack, or a room pack whose state you're allowed to send
1857+ pub editable: bool,
1858+ /// listed in im.ponies.emote_rooms. always true for your own pack
1859+ pub enabled_everywhere: bool,
17401860 }
17411861
17421862 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/normalise/image_pack.rs+97 additions−26 deletions
@@ -1,6 +1,6 @@
11 use std::collections::{BTreeMap, BTreeSet};
22
3-use matrix_sdk::{Client, Room, deserialized_responses::RawAnySyncOrStrippedState};
3+use matrix_sdk::{Client, Room, RoomState, deserialized_responses::RawAnySyncOrStrippedState};
44 use ruma::{
55 OwnedRoomId,
66 events::{
@@ -18,41 +18,94 @@ pub(crate) const ENABLED_ROOMS: &str = "im.ponies.emote_rooms";
1818
1919 /// rooms enabled everywhere that the user hasn't joined are skipped, we hold no state for them
2020 pub(crate) async fn image_packs(client: &Client) -> Result<ImagePacks, Error> {
21+ let enabled = match account_data(client, ENABLED_ROOMS).await? {
22+ Some(content) => content
23+ .get_field::<EnabledRooms>("rooms")
24+ .ok()
25+ .flatten()
26+ .unwrap_or_default(),
27+ None => EnabledRooms::new(),
28+ };
2129 let mut rooms = imbl::HashMap::new();
2230 for room in client.joined_rooms() {
23- let packs = room_packs(&room).await?;
31+ let packs = room_packs(&room, enabled.get(room.room_id())).await?;
2432 if !packs.is_empty() {
2533 rooms.insert(room.room_id().to_owned(), packs);
2634 }
2735 }
2836 let mut global = Vec::new();
2937 if let Some(content) = account_data(client, USER_PACK).await? {
30- global.extend(pack("user".to_owned(), ImagePackSource::User, &content));
38+ global.extend(pack(
39+ "user".to_owned(),
40+ ImagePackSource::User,
41+ &content,
42+ true,
43+ true,
44+ ));
3145 }
32- if let Some(content) = account_data(client, ENABLED_ROOMS).await? {
33- let enabled = content
34- .get_field::<BTreeMap<OwnedRoomId, BTreeMap<String, Raw<JsonObject>>>>("rooms")
35- .ok()
36- .flatten()
37- .unwrap_or_default();
38- for (room_id, state_keys) in enabled {
39- let packs = rooms.get(&room_id).into_iter().flatten();
40- global.extend(
41- packs
42- .filter(|pack| match &pack.source {
43- ImagePackSource::Room { state_key, .. } => {
44- state_keys.contains_key(state_key)
45- }
46- ImagePackSource::User => false,
47- })
48- .cloned(),
49- );
50- }
46+ for room_id in enabled.keys() {
47+ let packs = rooms.get(room_id).into_iter().flatten();
48+ global.extend(packs.filter(|pack| pack.enabled_everywhere).cloned());
5149 }
5250 Ok(ImagePacks { global, rooms })
5351 }
5452
55-async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {
53+type EnabledRooms = BTreeMap<OwnedRoomId, BTreeMap<String, Raw<JsonObject>>>;
54+
55+pub(crate) async fn can_edit_packs(room: &Room) -> bool {
56+ room.state() == RoomState::Joined
57+ && room.power_levels().await.is_ok_and(|levels| {
58+ levels.user_can_send_state(room.own_user_id(), StateEventType::from(ROOM_PACKS))
59+ })
60+}
61+
62+/// what an id from `ImagePack::id` points at. room ids hold colons, so it's matched
63+/// against the rooms we know rather than split
64+pub(crate) fn resolve_pack_id(client: &Client, id: &str) -> Option<ImagePackSource> {
65+ if id == "user" {
66+ return Some(ImagePackSource::User);
67+ }
68+ let rest = id.strip_prefix("room:")?;
69+ client.joined_rooms().into_iter().find_map(|room| {
70+ let state_key = rest
71+ .strip_prefix(room.room_id().as_str())?
72+ .strip_prefix(':')?;
73+ Some(ImagePackSource::Room {
74+ room_id: room.room_id().to_owned(),
75+ state_key: state_key.to_owned(),
76+ })
77+ })
78+}
79+
80+/// the pack's raw content as the store has it, for reading. edits fetch the server's copy
81+pub(crate) async fn stored_pack(
82+ client: &Client,
83+ source: &ImagePackSource,
84+) -> Result<Option<JsonObject>, Error> {
85+ let content = match source {
86+ ImagePackSource::User => account_data(client, USER_PACK).await?,
87+ ImagePackSource::Room { room_id, state_key } => {
88+ let room = client.get_room(room_id).ok_or_else(Error::room_unknown)?;
89+ match room
90+ .get_state_event(StateEventType::from(ROOM_PACKS), state_key)
91+ .await
92+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?
93+ {
94+ Some(RawAnySyncOrStrippedState::Sync(event)) => {
95+ event.get_field::<Raw<JsonObject>>("content").ok().flatten()
96+ }
97+ _ => None,
98+ }
99+ }
100+ };
101+ Ok(content.and_then(|content| content.deserialize().ok()))
102+}
103+
104+async fn room_packs(
105+ room: &Room,
106+ enabled: Option<&BTreeMap<String, Raw<JsonObject>>>,
107+) -> Result<Vec<ImagePack>, Error> {
108+ let editable = can_edit_packs(room).await;
56109 let events = room
57110 .get_state_events(StateEventType::from(ROOM_PACKS))
58111 .await
@@ -74,10 +127,14 @@ async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {
74127 room_id: room.room_id().to_owned(),
75128 state_key: state_key.clone(),
76129 };
130+ let enabled_everywhere =
131+ enabled.is_some_and(|enabled| enabled.contains_key(&state_key));
77132 pack(
78133 format!("room:{}:{state_key}", room.room_id()),
79134 source,
80135 &content,
136+ editable,
137+ enabled_everywhere,
81138 )
82139 })
83140 .collect())
@@ -92,8 +149,15 @@ async fn account_data(client: &Client, event_type: &str) -> Result<Option<Raw<Js
92149 .map(|content| content.cast_unchecked()))
93150 }
94151
95-/// one bad image costs that image, not its pack. a pack with nothing usable left is dropped
96-fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Option<ImagePack> {
152+/// one bad image costs that image, not its pack. only an emptied pack, which is how packs
153+/// get deleted, is dropped, so a fresh one shows up in a pack manager before it has images
154+fn pack(
155+ id: String,
156+ source: ImagePackSource,
157+ content: &Raw<JsonObject>,
158+ editable: bool,
159+ enabled_everywhere: bool,
160+) -> Option<ImagePack> {
97161 let info = content
98162 .get_field::<PackInfo>("pack")
99163 .ok()
@@ -126,7 +190,12 @@ fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Optio
126190 })
127191 })
128192 .collect();
129- (!images.is_empty()).then(|| ImagePack {
193+ let has_meta = content
194+ .get_field::<JsonObject>("pack")
195+ .ok()
196+ .flatten()
197+ .is_some();
198+ (has_meta || !images.is_empty()).then(|| ImagePack {
130199 id,
131200 display_name: info.display_name,
132201 avatar: info
@@ -136,6 +205,8 @@ fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Optio
136205 attribution: info.attribution,
137206 source,
138207 images,
208+ editable,
209+ enabled_everywhere,
139210 })
140211 }
141212
Modified file: src/normalise/mod.rs+3 additions−1 deletions
@@ -5,7 +5,9 @@ mod member;
55 mod room;
66 mod timeline;
77
8-pub(crate) use image_pack::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, image_packs};
8+pub(crate) use image_pack::{
9+ ENABLED_ROOMS, ROOM_PACKS, USER_PACK, can_edit_packs, image_packs, resolve_pack_id, stored_pack,
10+};
911 pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};
1012 pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};
1113 pub(crate) use member::member_summary;
Modified file: src/normalise/room.rs+1 additions
@@ -107,6 +107,7 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
107107 } else {
108108 None
109109 },
110+ can_edit_packs: super::can_edit_packs(room).await,
110111 }
111112 }
112113
Modified file: src/transport.rs+202 additions−7 deletions
@@ -28,13 +28,14 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
2929 use crate::{
3030 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
31- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePacks,
32- InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand,
33- MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,
34- MessageEditContent, NotificationCommand, OwnDevice, PowerLevelChanges, Profile,
35- PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,
36- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,
37- TimelineCommand, TimelineId, VerificationAction, VerificationTarget,
31+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePackSource,
32+ ImagePacks, InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media,
33+ MediaCommand, MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,
34+ MessageEditContent, NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage,
35+ Patch, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
36+ PushNotificationStatus, RoomCommand, RoomListFilter, RoomNotificationMode,
37+ RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,
38+ TimelineId, VerificationAction, VerificationTarget,
3839 encryption::encryption_state,
3940 engine::{MediaService, StoreLock},
4041 model::{PushNotificationRoom, PushNotificationSender},
@@ -736,6 +737,188 @@ impl ClientHandle {
736737 .await
737738 }
738739
740+ /// body defaults to the shortcode
741+ pub async fn send_sticker(
742+ &self,
743+ room_id: OwnedRoomId,
744+ pack_id: String,
745+ shortcode: String,
746+ ) -> Result<(), Error> {
747+ self.run(Command::Pack(PackCommand::SendSticker {
748+ room_id,
749+ pack_id,
750+ shortcode,
751+ }))
752+ .await
753+ }
754+
755+ /// fails if the state key already holds a pack
756+ pub async fn create_room_pack(
757+ &self,
758+ room_id: OwnedRoomId,
759+ state_key: String,
760+ display_name: String,
761+ ) -> Result<(), Error> {
762+ self.run(Command::Pack(PackCommand::CreateRoomPack {
763+ room_id,
764+ state_key,
765+ display_name,
766+ }))
767+ .await
768+ }
769+
770+ pub async fn delete_pack(&self, pack: ImagePackSource) -> Result<(), Error> {
771+ self.run(Command::Pack(PackCommand::Delete(pack))).await
772+ }
773+
774+ pub async fn set_pack_meta(
775+ &self,
776+ pack: ImagePackSource,
777+ display_name: Patch<String>,
778+ avatar: Patch<OwnedMxcUri>,
779+ usage: Patch<Vec<PackUsage>>,
780+ ) -> Result<(), Error> {
781+ self.run(Command::Pack(PackCommand::SetMeta {
782+ pack,
783+ display_name,
784+ avatar,
785+ usage,
786+ }))
787+ .await
788+ }
789+
790+ /// adds or replaces by shortcode. a replaced image keeps its info unless the url changed
791+ pub async fn put_pack_image(
792+ &self,
793+ pack: ImagePackSource,
794+ shortcode: String,
795+ mxc: OwnedMxcUri,
796+ body: Option<String>,
797+ usage: Option<Vec<PackUsage>>,
798+ info: Option<PackImageInfo>,
799+ ) -> Result<(), Error> {
800+ self.run(Command::Pack(PackCommand::PutImage {
801+ pack,
802+ shortcode,
803+ mxc,
804+ body,
805+ usage,
806+ info,
807+ }))
808+ .await
809+ }
810+
811+ pub async fn rename_pack_image(
812+ &self,
813+ pack: ImagePackSource,
814+ from: String,
815+ to: String,
816+ ) -> Result<(), Error> {
817+ self.run(Command::Pack(PackCommand::RenameImage { pack, from, to }))
818+ .await
819+ }
820+
821+ pub async fn remove_pack_image(
822+ &self,
823+ pack: ImagePackSource,
824+ shortcode: String,
825+ ) -> Result<(), Error> {
826+ self.run(Command::Pack(PackCommand::RemoveImage { pack, shortcode }))
827+ .await
828+ }
829+
830+ pub async fn set_pack_enabled_everywhere(
831+ &self,
832+ room_id: OwnedRoomId,
833+ state_key: String,
834+ enabled: bool,
835+ ) -> Result<(), Error> {
836+ self.run(Command::Pack(PackCommand::SetEnabledEverywhere {
837+ room_id,
838+ state_key,
839+ enabled,
840+ }))
841+ .await
842+ }
843+
844+ /// the same uri when it already lives on your homeserver
845+ pub async fn rehost_media(&self, mxc: OwnedMxcUri) -> Result<OwnedMxcUri, Error> {
846+ match self
847+ .execute(Command::Pack(PackCommand::Rehost(mxc)))
848+ .await?
849+ {
850+ CommandOutput::Mxc(uri) => Ok(uri),
851+ output => Err(unexpected("an mxc", &output)),
852+ }
853+ }
854+
855+ /// hands back the shortcode it ended up under
856+ pub async fn clone_pack_image(
857+ &self,
858+ from_pack: String,
859+ shortcode: String,
860+ to: ImagePackSource,
861+ ) -> Result<String, Error> {
862+ let (mut shortcodes, _) = self
863+ .run_clone(PackCommand::CloneImage {
864+ from_pack,
865+ shortcode,
866+ to,
867+ })
868+ .await?;
869+ shortcodes
870+ .pop()
871+ .ok_or_else(|| unexpected("a shortcode", &CommandOutput::Done))
872+ }
873+
874+ /// hands back the shortcode it ended up under
875+ pub async fn clone_sticker(
876+ &self,
877+ room_id: OwnedRoomId,
878+ event_id: OwnedEventId,
879+ to: ImagePackSource,
880+ shortcode: String,
881+ ) -> Result<String, Error> {
882+ let (mut shortcodes, _) = self
883+ .run_clone(PackCommand::CloneSticker {
884+ room_id,
885+ event_id,
886+ to,
887+ shortcode,
888+ })
889+ .await?;
890+ shortcodes
891+ .pop()
892+ .ok_or_else(|| unexpected("a shortcode", &CommandOutput::Done))
893+ }
894+
895+ /// hands back the shortcodes that couldn't be fetched, everything else is written
896+ pub async fn clone_pack(
897+ &self,
898+ from_pack: String,
899+ to: ImagePackSource,
900+ display_name: Option<String>,
901+ ) -> Result<Vec<String>, Error> {
902+ let (_, skipped) = self
903+ .run_clone(PackCommand::ClonePack {
904+ from_pack,
905+ to,
906+ display_name,
907+ })
908+ .await?;
909+ Ok(skipped)
910+ }
911+
912+ async fn run_clone(&self, command: PackCommand) -> Result<(Vec<String>, Vec<String>), Error> {
913+ match self.execute(Command::Pack(command)).await? {
914+ CommandOutput::Cloned {
915+ shortcodes,
916+ skipped,
917+ } => Ok((shortcodes, skipped)),
918+ output => Err(unexpected("a clone", &output)),
919+ }
920+ }
921+
739922 pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {
740923 let matcher = filter.matcher();
741924 self.rooms()
@@ -1047,6 +1230,11 @@ impl Request {
10471230 pub(crate) struct Responder(oneshot::Sender<Result<CommandOutput, Error>>);
10481231
10491232 impl Responder {
1233+ pub(crate) fn channel() -> (Self, Pending) {
1234+ let (reply, pending) = oneshot::channel();
1235+ (Self(reply), Pending(pending))
1236+ }
1237+
10501238 pub(crate) fn send(self, result: Result<CommandOutput, Error>) {
10511239 if let Err(error) = &result {
10521240 tracing::debug!(%error, kind = ?error.kind, "command failed");
@@ -1241,3 +1429,10 @@ fn normalise_push_notification(
12411429 NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,
12421430 }
12431431 }
1432+
1433+fn unexpected(wanted: &str, output: &CommandOutput) -> Error {
1434+ Error::new(
1435+ ErrorKind::Other,
1436+ format!("expected {wanted} but got {output:?}"),
1437+ )
1438+}