1use futures::{FutureExt, TryStreamExt};
2use ruma::{
3 EventId, OwnedEventId, RoomId, UserId,
4 events::{
5 relation::{InReplyTo, Reply as ReplyRelation, Thread},
6 room::{
7 encrypted::Relation as EncryptedRelation,
8 message::{
9 Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
10 },
11 },
12 },
13};
14use serde::Deserialize;
15use tuwunel_core::{
16 Error, Event, Result, error,
17 error::default_log,
18 implement,
19 pdu::{MAX_PDU_BYTES, PduBuilder},
20 utils::{json::serialized_len, stream::IterStream, string::chunk},
21};
22
23use super::CommandOutput;
24use crate::rooms::state::RoomMutexGuard;
25
26type MessageRelation = Relation<RoomMessageEventContentWithoutRelation>;
28
29const EVENT_RESERVE: usize = 10_240;
33
34const CONTENT_BUDGET: usize = MAX_PDU_BYTES - EVENT_RESERVE;
36
37const RELATION_RESERVE: usize = 512;
41
42const SEGMENT_BUDGET: usize = CONTENT_BUDGET - RELATION_RESERVE;
44
45enum Mode {
47 Thread(OwnedEventId),
50
51 Reply(OwnedEventId),
53}
54
55#[derive(Deserialize)]
56struct ExtractRelatesTo {
57 #[serde(rename = "m.relates_to")]
58 relates_to: EncryptedRelation,
59}
60
61#[implement(super::Service)]
62pub(super) async fn handle_response(
63 &self,
64 output: CommandOutput,
65 reply_id: Option<&EventId>,
66) -> Result {
67 let Some(reply_id) = reply_id else {
68 return Ok(());
69 };
70
71 let Ok(pdu) = self.services.timeline.get_pdu(reply_id).await else {
72 error!(?reply_id, "Missing admin command in_reply_to event");
73 return Ok(());
74 };
75
76 let response_sender = if self.is_admin_room(pdu.room_id()).await {
77 &self.services.globals.server_user
78 } else {
79 pdu.sender()
80 };
81
82 let threads = self.services.server.config.admin_output_threads;
83 let mode = command_thread(&pdu)
84 .or_else(|| threads.then(|| reply_id.to_owned()))
85 .map_or_else(|| Mode::Reply(reply_id.to_owned()), Mode::Thread);
86
87 self.respond(&output, pdu.room_id(), response_sender, &mode)
88 .boxed()
89 .await
90}
91
92#[implement(super::Service)]
96async fn respond(
97 &self,
98 output: &CommandOutput,
99 room_id: &RoomId,
100 sender: &UserId,
101 mode: &Mode,
102) -> Result {
103 let markdown = matches!(output, CommandOutput::Markdown(_));
104 let max_events = self
105 .services
106 .server
107 .config
108 .admin_output_max_events;
109
110 let segments = (max_events != 0)
111 .then(|| {
112 chunk(output.as_str(), markdown, |text: &str| fits_segment(text, markdown))
113 .take(max_events.saturating_add(1))
114 .collect::<Vec<_>>()
115 })
116 .filter(|segments| segments.len() <= max_events);
117
118 match segments {
119 | Some(segments) =>
120 self.send_segments(&segments, room_id, sender, mode, markdown)
121 .boxed()
122 .await,
123 | None => {
124 let mut content = self.attach(output).await.unwrap_or_else(|e| {
125 error!(%e, "Failed to attach oversized admin command output");
126 notice(
127 &format!(
128 "Failed to attach command output: \"{e}\"\n\nThe original admin command \
129 may have finished successfully, but we could not return the output."
130 ),
131 markdown,
132 )
133 });
134
135 content.relates_to = Some(mode.relation(None));
136
137 self.respond_to_room(content, room_id, sender)
138 .boxed()
139 .await
140 },
141 }
142}
143
144#[implement(super::Service)]
147async fn send_segments(
148 &self,
149 segments: &[String],
150 room_id: &RoomId,
151 sender: &UserId,
152 mode: &Mode,
153 markdown: bool,
154) -> Result {
155 assert!(self.user_is_admin(sender).await, "sender is not admin");
156
157 let state_lock = self.services.state.mutex.lock(room_id).await;
158
159 let result = segments
160 .iter()
161 .try_stream()
162 .try_fold(None, async |previous: Option<OwnedEventId>, segment| {
163 let mut content = notice(segment, markdown);
164 content.relates_to = Some(mode.relation(previous.as_deref()));
165
166 self.services
167 .timeline
168 .build_and_append_pdu(
169 PduBuilder::timeline(&content),
170 sender,
171 room_id,
172 &state_lock,
173 )
174 .await
175 .map(Some)
176 })
177 .await;
178
179 if let Err(e) = result {
180 self.handle_response_error(e, room_id, sender, &state_lock)
181 .boxed()
182 .await
183 .unwrap_or_else(default_log);
184 }
185
186 Ok(())
187}
188
189#[implement(super::Service)]
190pub(super) async fn respond_to_room(
191 &self,
192 content: RoomMessageEventContent,
193 room_id: &RoomId,
194 user_id: &UserId,
195) -> Result {
196 assert!(self.user_is_admin(user_id).await, "sender is not admin");
197
198 let state_lock = self.services.state.mutex.lock(room_id).await;
199
200 if let Err(e) = self
201 .services
202 .timeline
203 .build_and_append_pdu(PduBuilder::timeline(&content), user_id, room_id, &state_lock)
204 .await
205 {
206 self.handle_response_error(e, room_id, user_id, &state_lock)
207 .boxed()
208 .await
209 .unwrap_or_else(default_log);
210 }
211
212 Ok(())
213}
214
215#[implement(super::Service)]
216async fn handle_response_error(
217 &self,
218 e: Error,
219 room_id: &RoomId,
220 user_id: &UserId,
221 state_lock: &RoomMutexGuard,
222) -> Result {
223 error!(%e, "Failed to build and append admin room response PDU");
224 let content = RoomMessageEventContent::text_plain(format!(
225 "Failed to build and append admin room PDU: \"{e}\"\n\nThe original admin command may \
226 have finished successfully, but we could not return the output."
227 ));
228
229 self.services
230 .timeline
231 .build_and_append_pdu(PduBuilder::timeline(&content), user_id, room_id, state_lock)
232 .boxed()
233 .await?;
234
235 Ok(())
236}
237
238fn command_thread(pdu: &impl Event) -> Option<OwnedEventId> {
241 pdu.get_content()
242 .ok()
243 .and_then(|content: ExtractRelatesTo| match content.relates_to {
244 | EncryptedRelation::Thread(thread) => Some(thread.event_id),
245 | _ => None,
246 })
247}
248
249fn fits_segment(text: &str, markdown: bool) -> bool {
250 serialized_len(¬ice(text, markdown)).is_ok_and(|len| len <= SEGMENT_BUDGET)
251}
252
253impl Mode {
254 fn relation(&self, previous: Option<&EventId>) -> MessageRelation {
257 match self {
258 | Self::Thread(root) => thread_relation(root),
259 | Self::Reply(command) => reply_relation(previous.unwrap_or(command)),
260 }
261 }
262}
263
264fn notice(text: &str, markdown: bool) -> RoomMessageEventContent {
265 match markdown {
266 | true => RoomMessageEventContent::notice_markdown(text),
267 | false => RoomMessageEventContent::notice_plain(text),
268 }
269}
270
271fn thread_relation(root: &EventId) -> MessageRelation {
272 Relation::Thread(Thread::without_fallback(root.to_owned()))
273}
274
275fn reply_relation(event_id: &EventId) -> MessageRelation {
276 Relation::Reply(ReplyRelation {
277 in_reply_to: InReplyTo { event_id: event_id.to_owned() },
278 })
279}
280
281#[cfg(test)]
282mod tests {
283 use ruma::{EventId, ID_MAX_BYTES};
284
285 use super::{RELATION_RESERVE, notice, reply_relation, thread_relation};
286
287 #[test]
290 fn relation_reserve_covers_max_length_event_id() {
291 let raw = format!("${}", "a".repeat(ID_MAX_BYTES.saturating_sub(1)));
292 let event_id = <&EventId>::try_from(raw.as_str()).expect("valid max-length event id");
293 let bare = serde_json::to_string(¬ice("body", true))
294 .expect("serialize")
295 .len();
296
297 for relation in [reply_relation(event_id), thread_relation(event_id)] {
298 let mut content = notice("body", true);
299 content.relates_to = Some(relation);
300
301 let full = serde_json::to_string(&content)
302 .expect("serialize")
303 .len();
304
305 assert!(full.saturating_sub(bare) <= RELATION_RESERVE);
306 }
307 }
308}