Skip to main content

sccp_protocol/phone/xml/
conference.rs

1//! Conference phone XML document family.
2
3use super::*;
4
5/// Display schema used when rendering conference workflows.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub enum ConferenceMenuFamily {
8    Menu,
9    IconMenu,
10}
11
12/// Participant state needed to render conference menus and allowed actions.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct ConferenceListEntry {
15    pub participant_id: ParticipantId,
16    pub name: String,
17    pub number: String,
18    pub moderator: bool,
19    pub muted: bool,
20}
21
22/// Typed callback encoded into conference menu action URLs.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum ConferenceListAction {
25    /// Open the action menu for one participant.
26    Participant {
27        conference_id: ConferenceId,
28        participant_id: ParticipantId,
29    },
30    Mute {
31        conference_id: ConferenceId,
32        participant_id: ParticipantId,
33    },
34    Unmute {
35        conference_id: ConferenceId,
36        participant_id: ParticipantId,
37    },
38    Remove {
39        conference_id: ConferenceId,
40        participant_id: ParticipantId,
41    },
42    Promote {
43        conference_id: ConferenceId,
44        participant_id: ParticipantId,
45    },
46    Demote {
47        conference_id: ConferenceId,
48        participant_id: ParticipantId,
49    },
50    End {
51        conference_id: ConferenceId,
52    },
53}
54
55impl ConferenceListAction {
56    /// Application identifier embedded in generated callback URLs.
57    pub const APPLICATION_ID: u32 = 9091;
58
59    /// Encodes the action as a device-local callback URL.
60    pub fn url(self) -> String {
61        match self {
62            Self::Participant {
63                conference_id,
64                participant_id,
65            } => format!(
66                "UserData:{}:0:conference/{}/participant/{}",
67                Self::APPLICATION_ID,
68                conference_id.get(),
69                participant_id.get()
70            ),
71            Self::Mute {
72                conference_id,
73                participant_id,
74            } => format!(
75                "UserData:{}:0:conference/{}/participant/{}/mute",
76                Self::APPLICATION_ID,
77                conference_id.get(),
78                participant_id.get()
79            ),
80            Self::Unmute {
81                conference_id,
82                participant_id,
83            } => format!(
84                "UserData:{}:0:conference/{}/participant/{}/unmute",
85                Self::APPLICATION_ID,
86                conference_id.get(),
87                participant_id.get()
88            ),
89            Self::Remove {
90                conference_id,
91                participant_id,
92            } => format!(
93                "UserData:{}:0:conference/{}/participant/{}/remove",
94                Self::APPLICATION_ID,
95                conference_id.get(),
96                participant_id.get()
97            ),
98            Self::Promote {
99                conference_id,
100                participant_id,
101            } => format!(
102                "UserData:{}:0:conference/{}/participant/{}/promote",
103                Self::APPLICATION_ID,
104                conference_id.get(),
105                participant_id.get()
106            ),
107            Self::Demote {
108                conference_id,
109                participant_id,
110            } => format!(
111                "UserData:{}:0:conference/{}/participant/{}/demote",
112                Self::APPLICATION_ID,
113                conference_id.get(),
114                participant_id.get()
115            ),
116            Self::End { conference_id } => format!(
117                "UserData:{}:0:conference/{}/end",
118                Self::APPLICATION_ID,
119                conference_id.get()
120            ),
121        }
122    }
123
124    /// Parses a complete callback URL or its bare `conference/...` path.
125    pub fn parse(value: &str) -> Option<Self> {
126        let path = value
127            .trim_matches(['\0', ' ', '\r', '\n'])
128            .strip_prefix(&format!("UserData:{}:0:", Self::APPLICATION_ID))
129            .unwrap_or(value)
130            .strip_prefix("conference/")?;
131        let segments: Vec<_> = path.split('/').collect();
132        let [conference_id, action, rest @ ..] = segments.as_slice() else {
133            return None;
134        };
135        let conference_id = ConferenceId::new(conference_id.parse().ok()?);
136        match (*action, rest) {
137            ("participant", [participant]) => Some(Self::Participant {
138                conference_id,
139                participant_id: ParticipantId::new(participant.parse().ok()?),
140            }),
141            ("participant", [participant, "mute"]) => Some(Self::Mute {
142                conference_id,
143                participant_id: ParticipantId::new(participant.parse().ok()?),
144            }),
145            ("participant", [participant, "unmute"]) => Some(Self::Unmute {
146                conference_id,
147                participant_id: ParticipantId::new(participant.parse().ok()?),
148            }),
149            ("participant", [participant, "remove"]) => Some(Self::Remove {
150                conference_id,
151                participant_id: ParticipantId::new(participant.parse().ok()?),
152            }),
153            ("participant", [participant, "promote"]) => Some(Self::Promote {
154                conference_id,
155                participant_id: ParticipantId::new(participant.parse().ok()?),
156            }),
157            ("participant", [participant, "demote"]) => Some(Self::Demote {
158                conference_id,
159                participant_id: ParticipantId::new(participant.parse().ok()?),
160            }),
161            ("end", []) => Some(Self::End { conference_id }),
162            _ => None,
163        }
164    }
165
166    /// Parses the percent-decoded route produced by a service submission.
167    pub fn from_route(route: &[String]) -> Option<Self> {
168        let [conference, conference_id, action, rest @ ..] = route else {
169            return None;
170        };
171        if conference != "conference" {
172            return None;
173        }
174        let conference_id = ConferenceId::new(conference_id.parse().ok()?);
175        match (action.as_str(), rest) {
176            ("participant", [participant]) => Some(Self::Participant {
177                conference_id,
178                participant_id: ParticipantId::new(participant.parse().ok()?),
179            }),
180            ("participant", [participant, operation]) if operation == "mute" => Some(Self::Mute {
181                conference_id,
182                participant_id: ParticipantId::new(participant.parse().ok()?),
183            }),
184            ("participant", [participant, operation]) if operation == "unmute" => {
185                Some(Self::Unmute {
186                    conference_id,
187                    participant_id: ParticipantId::new(participant.parse().ok()?),
188                })
189            }
190            ("participant", [participant, operation]) if operation == "remove" => {
191                Some(Self::Remove {
192                    conference_id,
193                    participant_id: ParticipantId::new(participant.parse().ok()?),
194                })
195            }
196            ("participant", [participant, operation]) if operation == "promote" => {
197                Some(Self::Promote {
198                    conference_id,
199                    participant_id: ParticipantId::new(participant.parse().ok()?),
200                })
201            }
202            ("participant", [participant, operation]) if operation == "demote" => {
203                Some(Self::Demote {
204                    conference_id,
205                    participant_id: ParticipantId::new(participant.parse().ok()?),
206                })
207            }
208            ("end", []) => Some(Self::End { conference_id }),
209            _ => None,
210        }
211    }
212}
213
214/// Rendered conference overview in either supported menu family.
215#[derive(Clone, Debug, Eq, PartialEq)]
216pub enum ConferenceListDocument {
217    Menu(CiscoIpPhoneMenu),
218    IconMenu(CiscoIpPhoneIconMenu),
219}
220
221impl ConferenceListDocument {
222    /// Renders a conference overview and validates all menu and byte bounds.
223    pub fn new(
224        conference_id: ConferenceId,
225        participants: &[ConferenceListEntry],
226        family: ConferenceMenuFamily,
227    ) -> Result<Self, PhoneXmlError> {
228        if participants.len() > CONFERENCE_LIST_MAX_PARTICIPANTS {
229            return Err(PhoneXmlError::LimitExceeded {
230                kind: "conference participants",
231                actual: participants.len(),
232                maximum: CONFERENCE_LIST_MAX_PARTICIPANTS,
233            });
234        }
235        let title = format!("Conference {}", conference_id.get());
236        let prompt = if participants.is_empty() {
237            "No participants".to_owned()
238        } else {
239            "Select a participant".to_owned()
240        };
241        match family {
242            ConferenceMenuFamily::Menu => CiscoIpPhoneMenu::new(
243                title,
244                prompt,
245                participants
246                    .iter()
247                    .map(|participant| CiscoIpPhoneMenuItem {
248                        name: Some(conference_participant_label(participant)),
249                        url: Some(
250                            ConferenceListAction::Participant {
251                                conference_id,
252                                participant_id: participant.participant_id,
253                            }
254                            .url(),
255                        ),
256                    })
257                    .chain(std::iter::once(CiscoIpPhoneMenuItem {
258                        name: Some("End conference".into()),
259                        url: Some(ConferenceListAction::End { conference_id }.url()),
260                    }))
261                    .collect(),
262            )
263            .map(Self::Menu),
264            ConferenceMenuFamily::IconMenu => CiscoIpPhoneIconMenu::new(
265                title,
266                prompt,
267                participants
268                    .iter()
269                    .map(|participant| CiscoIpPhoneIconMenuItem {
270                        name: Some(conference_participant_label(participant)),
271                        url: Some(
272                            ConferenceListAction::Participant {
273                                conference_id,
274                                participant_id: participant.participant_id,
275                            }
276                            .url(),
277                        ),
278                        icon_index: Some(u16::from(participant.moderator)),
279                    })
280                    .chain(std::iter::once(CiscoIpPhoneIconMenuItem {
281                        name: Some("End conference".into()),
282                        url: Some(ConferenceListAction::End { conference_id }.url()),
283                        icon_index: Some(0),
284                    }))
285                    .collect(),
286                conference_icons(),
287            )
288            .map(Self::IconMenu),
289        }
290    }
291
292    /// Serializes the rendered menu within [`CONFERENCE_LIST_MAX_BYTES`].
293    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
294        match self {
295            Self::Menu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
296            Self::IconMenu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
297        }
298    }
299
300    /// Parses a conference overview using the caller-selected menu family.
301    pub fn from_xml(document: &[u8], family: ConferenceMenuFamily) -> Result<Self, PhoneXmlError> {
302        match family {
303            ConferenceMenuFamily::Menu => {
304                CiscoIpPhoneMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
305                    .map(Self::Menu)
306            }
307            ConferenceMenuFamily::IconMenu => {
308                CiscoIpPhoneIconMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
309                    .map(Self::IconMenu)
310            }
311        }
312    }
313
314    /// Iterates recognized callback actions in display order.
315    pub fn actions(&self) -> impl Iterator<Item = ConferenceListAction> + '_ {
316        let urls: Box<dyn Iterator<Item = &str>> = match self {
317            Self::Menu(document) => {
318                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
319            }
320            Self::IconMenu(document) => {
321                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
322            }
323        };
324        urls.filter_map(ConferenceListAction::parse)
325    }
326}
327
328/// Participant-specific conference actions in either supported menu family.
329#[derive(Clone, Debug, Eq, PartialEq)]
330pub enum ConferenceParticipantActionsDocument {
331    Menu(CiscoIpPhoneMenu),
332    IconMenu(CiscoIpPhoneIconMenu),
333}
334
335impl ConferenceParticipantActionsDocument {
336    /// Renders the actions currently permitted for one participant.
337    ///
338    /// `removable` and `demotable` let session policy suppress actions even
339    /// when the participant state would otherwise permit them.
340    pub fn new(
341        conference_id: ConferenceId,
342        participant: &ConferenceListEntry,
343        removable: bool,
344        demotable: bool,
345        family: ConferenceMenuFamily,
346    ) -> Result<Self, PhoneXmlError> {
347        let mut actions = Vec::new();
348        if participant.moderator {
349            if demotable {
350                actions.push((
351                    "Demote",
352                    ConferenceListAction::Demote {
353                        conference_id,
354                        participant_id: participant.participant_id,
355                    },
356                ));
357            }
358        } else {
359            let (toggle_name, toggle) = if participant.muted {
360                (
361                    "Unmute",
362                    ConferenceListAction::Unmute {
363                        conference_id,
364                        participant_id: participant.participant_id,
365                    },
366                )
367            } else {
368                (
369                    "Mute",
370                    ConferenceListAction::Mute {
371                        conference_id,
372                        participant_id: participant.participant_id,
373                    },
374                )
375            };
376            actions.push((toggle_name, toggle));
377            if removable {
378                actions.push((
379                    "Remove",
380                    ConferenceListAction::Remove {
381                        conference_id,
382                        participant_id: participant.participant_id,
383                    },
384                ));
385            }
386            actions.push((
387                "Promote",
388                ConferenceListAction::Promote {
389                    conference_id,
390                    participant_id: participant.participant_id,
391                },
392            ));
393        }
394        let title = format!("Participant {}", participant.participant_id.get());
395        match family {
396            ConferenceMenuFamily::Menu => CiscoIpPhoneMenu::new(
397                title,
398                "Choose an action",
399                actions
400                    .into_iter()
401                    .map(|(name, action)| CiscoIpPhoneMenuItem {
402                        name: Some(name.into()),
403                        url: Some(action.url()),
404                    })
405                    .collect(),
406            )
407            .map(Self::Menu),
408            ConferenceMenuFamily::IconMenu => CiscoIpPhoneIconMenu::new(
409                title,
410                "Choose an action",
411                actions
412                    .into_iter()
413                    .map(|(name, action)| CiscoIpPhoneIconMenuItem {
414                        name: Some(name.into()),
415                        url: Some(action.url()),
416                        icon_index: None,
417                    })
418                    .collect(),
419                Vec::new(),
420            )
421            .map(Self::IconMenu),
422        }
423    }
424
425    /// Serializes the rendered menu within [`CONFERENCE_LIST_MAX_BYTES`].
426    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
427        match self {
428            Self::Menu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
429            Self::IconMenu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
430        }
431    }
432
433    /// Parses participant actions using the caller-selected menu family.
434    pub fn from_xml(document: &[u8], family: ConferenceMenuFamily) -> Result<Self, PhoneXmlError> {
435        match family {
436            ConferenceMenuFamily::Menu => {
437                CiscoIpPhoneMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
438                    .map(Self::Menu)
439            }
440            ConferenceMenuFamily::IconMenu => {
441                CiscoIpPhoneIconMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
442                    .map(Self::IconMenu)
443            }
444        }
445    }
446
447    /// Iterates recognized callback actions in display order.
448    pub fn actions(&self) -> impl Iterator<Item = ConferenceListAction> + '_ {
449        let urls: Box<dyn Iterator<Item = &str>> = match self {
450            Self::Menu(document) => {
451                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
452            }
453            Self::IconMenu(document) => {
454                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
455            }
456        };
457        urls.filter_map(ConferenceListAction::parse)
458    }
459}
460
461pub(super) fn conference_participant_label(participant: &ConferenceListEntry) -> String {
462    let identity = if !participant.name.trim().is_empty() {
463        participant.name.trim()
464    } else if !participant.number.trim().is_empty() {
465        participant.number.trim()
466    } else {
467        "Unknown participant"
468    };
469    let role = if participant.moderator {
470        "Moderator"
471    } else {
472        "Participant"
473    };
474    let mute = if participant.muted { ", muted" } else { "" };
475    format!("{identity} ({role}{mute})")
476}
477
478pub(super) fn conference_icons() -> Vec<CiscoIpPhoneIconItem> {
479    vec![
480        CiscoIpPhoneIconItem {
481            index: 0,
482            width: 10,
483            height: 10,
484            depth: 2,
485            data: Some("00000000000000000000000000".into()),
486        },
487        CiscoIpPhoneIconItem {
488            index: 1,
489            width: 10,
490            height: 10,
491            depth: 2,
492            data: Some("00000155415555554155000000".into()),
493        },
494    ]
495}