Skip to main content

opentalk_roomserver_module_whiteboard/
lib.rs

1// SPDX-FileCopyrightText: OpenTalk GmbH <mail@opentalk.eu>
2// SPDX-License-Identifier: EUPL-1.2
3
4use 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            // There is no whiteboard in the new room, nothing to do.
189            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}