opentalk_roomserver_module_whiteboard/
lib.rs1use std::{
5 collections::{BTreeMap, HashMap},
6 sync::Arc,
7};
8
9use anyhow::anyhow;
10use futures::{StreamExt, stream};
11use opentalk_roomserver_signaling::{
12 module_context::ModuleContext,
13 signaling_module::{
14 ModuleJoinData, ModuleSwitchData, NoOp, PeerDataMap, SignalingModule,
15 SignalingModuleDescription, SignalingModuleFeatureDescription, SignalingModuleInitData,
16 },
17 storage::assets::ModuleAssetStorage,
18};
19use opentalk_roomserver_types::{
20 connection_id::ConnectionId,
21 room_kind::RoomKind,
22 signaling::module_error::{FatalError, SignalingModuleError},
23};
24use opentalk_roomserver_types_whiteboard::{
25 WHITEBOARD_MODULE_ID, WhiteboardCommand, WhiteboardError, WhiteboardEvent, WhiteboardSettings,
26 WhiteboardState, state::SpaceInfo,
27};
28use opentalk_types_common::{modules::ModuleId, time::Timestamp};
29use opentalk_types_signaling::ParticipantId;
30use tracing::{Instrument, Span};
31
32use crate::{client::SpacedeckClient, loopback::WhiteboardLoopback};
33
34const PARALLEL_UPDATES: usize = 25;
35
36pub mod client;
37mod loopback;
38
39enum InitState {
40 Initializing,
41 Initialized(SpaceInfo),
42}
43
44impl From<&InitState> for WhiteboardState {
45 fn from(value: &InitState) -> Self {
46 match value {
47 InitState::Initializing => Self::Initializing,
48 InitState::Initialized(SpaceInfo { url, .. }) => Self::Initialized(url.clone()),
49 }
50 }
51}
52
53pub struct WhiteboardModule {
54 state: HashMap<RoomKind, InitState>,
55 client: Arc<SpacedeckClient>,
56}
57
58impl SignalingModuleDescription for WhiteboardModule {
59 const MODULE_ID: ModuleId = WHITEBOARD_MODULE_ID;
60 const DESCRIPTION: &'static str = "Handles whiteboard integration. The whiteboard is a collaborative drawing board that can be used during the meeting.";
61 const FEATURES: &[SignalingModuleFeatureDescription] = &[];
62}
63
64impl SignalingModule for WhiteboardModule {
65 const NAMESPACE: ModuleId = WHITEBOARD_MODULE_ID;
66
67 type Incoming = WhiteboardCommand;
68
69 type Outgoing = WhiteboardEvent;
70
71 type Internal = NoOp;
72
73 type Loopback = Result<WhiteboardLoopback, SignalingModuleError<WhiteboardError>>;
74
75 type JoinInfo = WhiteboardState;
76
77 type PeerJoinInfo = ();
78
79 type Error = WhiteboardError;
80
81 fn init(init_data: SignalingModuleInitData) -> Option<Self> {
82 let settings = init_data
83 .room_parameters
84 .module_settings
85 .get::<WhiteboardSettings>()
86 .inspect_err(|err| {
87 tracing::error!("Failed to deserialize whiteboard settings: {err:?}")
88 })
89 .ok()??;
90 let spacedeck = SpacedeckClient::new(settings.base_url, settings.api_key);
91
92 Some(Self {
93 state: HashMap::new(),
94 client: Arc::new(spacedeck),
95 })
96 }
97
98 fn on_participant_joined(
99 &mut self,
100 ctx: &mut ModuleContext<'_, Self>,
101 _participant_id: ParticipantId,
102 _connection_id: ConnectionId,
103 _is_first_connection: bool,
104 ) -> Result<ModuleJoinData<Self>, SignalingModuleError<Self::Error>> {
105 if let Some(state) = self.state.get(&ctx.room) {
106 Ok(ModuleJoinData {
107 join_success: Some(state.into()),
108 peer_events: PeerDataMap::default(),
109 peer_data: PeerDataMap::default(),
110 })
111 } else {
112 Ok(ModuleJoinData::default())
113 }
114 }
115
116 #[allow(unused_variables)]
117 fn on_participant_disconnected(
118 &mut self,
119 ctx: &mut ModuleContext<'_, Self>,
120 participant_id: ParticipantId,
121 connection_id: ConnectionId,
122 ) -> Result<(), SignalingModuleError<Self::Error>> {
123 Ok(())
124 }
125
126 fn on_websocket_message(
127 &mut self,
128 ctx: &mut ModuleContext<'_, Self>,
129 sender: ParticipantId,
130 _connection_id: ConnectionId,
131 payload: Self::Incoming,
132 ) -> Result<(), SignalingModuleError<Self::Error>> {
133 match payload {
134 WhiteboardCommand::Initialize => self.initialize(ctx, sender),
135 WhiteboardCommand::GeneratePdf => self.generate_pdf(ctx, sender),
136 }
137 }
138
139 fn on_loopback_event(
140 &mut self,
141 ctx: &mut ModuleContext<'_, Self>,
142 event: Self::Loopback,
143 ) -> Result<(), SignalingModuleError<Self::Error>> {
144 match event {
145 Ok(WhiteboardLoopback::SpaceCreated { info }) => {
146 tracing::debug!(
147 "Spacedeck space for room {:?} created: {:?}",
148 ctx.room,
149 info.id
150 );
151 let url = info.url.clone();
152 let previous = self.state.insert(ctx.room, InitState::Initialized(info));
153 if let Some(previous) = previous
154 && matches!(previous, InitState::Initialized(..))
155 {
156 tracing::warn!("Spacedeck created, but a previous one already existed");
157 }
158 ctx.send_ws_message(
159 ctx.participants.in_room(ctx.room).connected().ids(),
160 WhiteboardEvent::Initialized { url },
161 )?;
162 }
163 Ok(WhiteboardLoopback::PdfCreated { asset }) => ctx.send_ws_message(
164 ctx.participants.in_room(ctx.room).connected().ids(),
165 WhiteboardEvent::PdfCreated {
166 filename: asset.filename,
167 asset_id: asset.id,
168 },
169 )?,
170 Err(SignalingModuleError::Module(WhiteboardError::InitializationFailed)) => {
171 self.state.remove(&ctx.room);
172 return Err(WhiteboardError::InitializationFailed.into());
173 }
174 Err(err) => return Err(err),
175 };
176
177 Ok(())
178 }
179
180 fn on_breakout_switch(
181 &mut self,
182 ctx: &mut ModuleContext<'_, Self>,
183 participant_id: ParticipantId,
184 _old_room: RoomKind,
185 new_room: RoomKind,
186 ) -> Result<ModuleSwitchData<Self>, SignalingModuleError<Self::Error>> {
187 let Some(state) = self.state.get(&new_room) else {
188 return Ok(ModuleSwitchData::default());
190 };
191
192 let switch_success: BTreeMap<ConnectionId, Option<WhiteboardState>> = ctx
193 .participant_state(participant_id)
194 .ok_or(FatalError(anyhow!(
195 "Participant {participant_id:?} switched without participant state"
196 )))?
197 .connections()
198 .map(|connection_id| (connection_id, Some(state.into())))
199 .collect();
200
201 Ok(ModuleSwitchData {
202 switch_success,
203 peer_events: PeerDataMap::default(),
204 peer_data: PeerDataMap::default(),
205 })
206 }
207
208 fn on_breakout_closed(
209 &mut self,
210 ctx: &mut ModuleContext<'_, Self>,
211 ) -> Result<(), SignalingModuleError<Self::Error>> {
212 let breakout_rooms: Vec<String> = self
213 .state
214 .extract_if(|&room, _| room != RoomKind::Main)
215 .filter_map(|(.., state)| match state {
216 InitState::Initializing => None,
217 InitState::Initialized(SpaceInfo { id, .. }) => Some(id),
218 })
219 .collect();
220 Self::delete_spaces(
221 Arc::clone(&self.client),
222 ctx.assets(),
223 breakout_rooms,
224 ctx.timestamp,
225 );
226
227 Ok(())
228 }
229
230 fn on_closing(&mut self, ctx: &mut ModuleContext<'_, Self>) -> anyhow::Result<()> {
231 let spaces: Vec<String> = self
232 .state
233 .drain()
234 .filter_map(|(.., state)| match state {
235 InitState::Initializing => None,
236 InitState::Initialized(SpaceInfo { id, .. }) => Some(id),
237 })
238 .collect();
239 Self::delete_spaces(
240 Arc::clone(&self.client),
241 ctx.assets(),
242 spaces,
243 Timestamp::now(),
244 );
245
246 Ok(())
247 }
248}
249
250impl WhiteboardModule {
251 fn initialize(
252 &mut self,
253 ctx: &mut ModuleContext<'_, Self>,
254 sender: ParticipantId,
255 ) -> Result<(), SignalingModuleError<WhiteboardError>> {
256 if !ctx.is_moderator(sender) {
257 return Err(WhiteboardError::InsufficientPermissions.into());
258 }
259
260 if let Some(state) = self.state.get(&ctx.room) {
261 match state {
262 InitState::Initializing => {
263 return Err(WhiteboardError::CurrentlyInitializing.into());
264 }
265 InitState::Initialized(..) => {
266 return Err(WhiteboardError::AlreadyInitialized.into());
267 }
268 }
269 }
270
271 self.state.insert(ctx.room, InitState::Initializing);
272 ctx.spawn(loopback::create_space(
273 Arc::clone(&self.client),
274 ctx.room_id,
275 ctx.room,
276 ));
277
278 ctx.send_ws_message(
279 ctx.participants.in_room(ctx.room).connected().ids(),
280 WhiteboardEvent::InitializationStarted,
281 )?;
282
283 Ok(())
284 }
285
286 fn generate_pdf(
287 &mut self,
288 ctx: &mut ModuleContext<'_, Self>,
289 sender: ParticipantId,
290 ) -> Result<(), SignalingModuleError<WhiteboardError>> {
291 if !ctx.is_moderator(sender) {
292 return Err(WhiteboardError::InsufficientPermissions.into());
293 }
294
295 let id = match self.state.get(&ctx.room) {
296 Some(InitState::Initialized(SpaceInfo { id, .. })) => id.to_owned(),
297 Some(InitState::Initializing) => {
298 return Err(WhiteboardError::CurrentlyInitializing.into());
299 }
300 None => return Err(WhiteboardError::NotInitialized.into()),
301 };
302 ctx.spawn(loopback::generate_pdf(
303 Arc::clone(&self.client),
304 ctx.assets(),
305 id,
306 ctx.timestamp,
307 ));
308
309 Ok(())
310 }
311
312 #[tracing::instrument(skip_all, fields(spaces), level = "debug")]
313 fn delete_spaces(
314 client: Arc<SpacedeckClient>,
315 storage: ModuleAssetStorage,
316 spaces: Vec<String>,
317 timestamp: Timestamp,
318 ) {
319 let span = Span::current();
320 let future = stream::iter(spaces)
321 .map(move |id| {
322 let client = Arc::clone(&client);
323 let storage_client = storage.clone();
324 let span = span.clone();
325
326 async move {
327 loopback::delete_space(client, storage_client, id, timestamp).await;
328 }
329 .instrument(span)
330 })
331 .buffer_unordered(PARALLEL_UPDATES)
332 .collect::<Vec<()>>()
333 .in_current_span();
334 tokio::spawn(future);
335 }
336}