Skip to main content

freeswitch_log_parser/session/
conference.rs

1//! Conference membership tracking.
2//!
3//! `mod_conference` never logs a member id, so the only always-available signal
4//! is the conference name, from the `conference` application's EXECUTE trace or
5//! from a transfer into an inline `conference:` extension. Member id and
6//! FreeSWITCH's own conference UUID reach the log only as channel variables.
7
8use std::collections::{HashMap, HashSet};
9
10use freeswitch_types::variables::ConferenceVariable;
11
12use crate::message::MessageKind;
13use crate::stream::LogEntry;
14
15/// A session's membership in one conference.
16///
17/// `instance` distinguishes successive conferences that share a name; see
18/// `docs/design-rationale.md`.
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ConferenceMembership {
22    /// Conference name as dialed, e.g. `835`.
23    pub name: String,
24    /// Profile from `conference(name@profile)`; `None` when the argument omits
25    /// it and FreeSWITCH applies its own default.
26    pub profile: Option<String>,
27    /// UUID of the first channel to join this conference instance.
28    pub instance: String,
29    /// From the `conference_member_id` channel variable; `None` until a dump
30    /// or a variable line carries it.
31    pub member_id: Option<u32>,
32    /// FreeSWITCH's own conference UUID, from the `conference_uuid` channel
33    /// variable; `None` until a dump or a variable line carries it.
34    pub conference_uuid: Option<String>,
35}
36
37/// Conference a session is joining, as named by a log line.
38pub(crate) struct ConferenceTarget {
39    pub name: String,
40    pub profile: Option<String>,
41}
42
43pub(crate) enum ConferenceEvent {
44    Join(ConferenceTarget),
45    Leave,
46}
47
48/// Live conferences, keyed by name. An instance exists only while it has
49/// members, so the next join on a reused name opens a new one.
50#[derive(Debug, Default)]
51pub(crate) struct ConferenceRegistry {
52    live: HashMap<String, Instance>,
53}
54
55#[derive(Debug)]
56struct Instance {
57    id: String,
58    members: HashSet<String>,
59}
60
61impl ConferenceRegistry {
62    pub(crate) fn instance_for(&self, name: &str) -> Option<&str> {
63        self.live.get(name).map(|i| i.id.as_str())
64    }
65
66    pub(crate) fn join(&mut self, name: &str, instance: &str, uuid: &str) {
67        self.live
68            .entry(name.to_string())
69            .or_insert_with(|| Instance {
70                id: instance.to_string(),
71                members: HashSet::new(),
72            })
73            .members
74            .insert(uuid.to_string());
75    }
76
77    pub(crate) fn leave(&mut self, name: &str, uuid: &str) {
78        if let Some(instance) = self.live.get_mut(name) {
79            instance.members.remove(uuid);
80            if instance.members.is_empty() {
81                self.live.remove(name);
82            }
83        }
84    }
85
86    pub(crate) fn members<'a>(&'a self, instance: &'a str) -> impl Iterator<Item = &'a str> {
87        self.live
88            .values()
89            .filter(move |i| i.id == instance)
90            .flat_map(|i| i.members.iter().map(String::as_str))
91    }
92}
93
94/// Conference join or leave named by this entry, if any.
95pub(crate) fn detect(entry: &LogEntry) -> Option<ConferenceEvent> {
96    if entry.message.starts_with("Channel leaving conference") {
97        return Some(ConferenceEvent::Leave);
98    }
99    if let MessageKind::Execute {
100        application,
101        arguments,
102        ..
103    } = &entry.message_kind
104    {
105        if application == "conference" {
106            return parse_target(arguments).map(ConferenceEvent::Join);
107        }
108    }
109    parse_transfer(&entry.message).map(ConferenceEvent::Join)
110}
111
112/// `Transfer <chan> to <dialplan>[<extension>@<context>]` — the trailing
113/// `@context` belongs to the transfer, not to the conference, so it is cut
114/// before the extension is read as a conference argument.
115fn parse_transfer(msg: &str) -> Option<ConferenceTarget> {
116    if !msg.starts_with("Transfer ") {
117        return None;
118    }
119    let start = msg.find("[conference:")? + "[conference:".len();
120    let extension = msg[start..].strip_suffix(']')?;
121    let extension = match extension.rfind('@') {
122        Some(at) => &extension[..at],
123        None => extension,
124    };
125    parse_target(extension)
126}
127
128/// Mirrors `mod_conference.c` `conference_function`: `+flags{…}` truncates the
129/// argument, a `bridge:` prefix is followed by `name:dialstring`, the pin
130/// starts at the first `+`, and the profile is what follows the *last* `@`.
131fn parse_target(arguments: &str) -> Option<ConferenceTarget> {
132    let mut spec = match arguments.find("+flags{") {
133        Some(at) => &arguments[..at],
134        None => arguments,
135    };
136    if let Some(rest) = spec.strip_prefix("bridge:") {
137        spec = rest.split_once(':').map(|(name, _)| name)?;
138    }
139    let spec = spec.trim_start_matches(' ');
140    let spec = match spec.split_once('+') {
141        Some((name, _pin)) => name,
142        None => spec,
143    };
144    let (name, profile) = match spec.rsplit_once('@') {
145        Some((name, profile)) => (name, Some(profile.to_string())),
146        None => (spec, None),
147    };
148    if name.is_empty() {
149        return None;
150    }
151    Some(ConferenceTarget {
152        name: name.to_string(),
153        profile,
154    })
155}
156
157/// A `conference_name` variable is a join signal in its own right: a recovered
158/// or dumped channel can carry it with no EXECUTE trace anywhere in the log.
159pub(crate) fn target_from_variables(vars: &HashMap<String, String>) -> Option<ConferenceTarget> {
160    Some(ConferenceTarget {
161        name: vars
162            .get(ConferenceVariable::ConferenceName.as_str())?
163            .clone(),
164        profile: None,
165    })
166}
167
168/// Fill in what only a channel dump can supply, leaving anything already known
169/// in place when the variable is absent.
170pub(crate) fn refresh(membership: &mut ConferenceMembership, vars: &HashMap<String, String>) {
171    if let Some(member_id) = vars
172        .get(ConferenceVariable::ConferenceMemberId.as_str())
173        .and_then(|v| v.parse().ok())
174    {
175        membership.member_id = Some(member_id);
176    }
177    if let Some(uuid) = vars.get(ConferenceVariable::ConferenceUuid.as_str()) {
178        membership.conference_uuid = Some(uuid.clone());
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    fn target(arguments: &str) -> (String, Option<String>) {
187        let t = parse_target(arguments).expect("parsed");
188        (t.name, t.profile)
189    }
190
191    #[test]
192    fn bare_name() {
193        assert_eq!(target("835"), ("835".to_string(), None));
194    }
195
196    #[test]
197    fn name_with_profile() {
198        assert_eq!(
199            target("835@wideband"),
200            ("835".to_string(), Some("wideband".to_string()))
201        );
202    }
203
204    #[test]
205    fn pin_is_not_part_of_the_name() {
206        assert_eq!(target("835+1234"), ("835".to_string(), None));
207        assert_eq!(
208            target("835@wideband+1234"),
209            ("835".to_string(), Some("wideband".to_string()))
210        );
211    }
212
213    #[test]
214    fn flags_truncate_the_argument() {
215        assert_eq!(
216            target("835@wideband+flags{mute|deaf}"),
217            ("835".to_string(), Some("wideband".to_string()))
218        );
219    }
220
221    #[test]
222    fn bridge_form_names_the_conference_before_the_dialstring() {
223        assert_eq!(
224            target("bridge:835:sofia/internal/1000"),
225            ("835".to_string(), None)
226        );
227    }
228
229    #[test]
230    fn empty_argument_is_not_a_conference() {
231        assert!(parse_target("").is_none());
232        assert!(parse_target("@wideband").is_none());
233    }
234
235    #[test]
236    fn transfer_context_is_not_the_profile() {
237        let t = parse_transfer("Transfer loopback/tty-a to inline[conference:835@default]")
238            .expect("parsed");
239        assert_eq!(t.name, "835");
240        assert_eq!(t.profile, None);
241    }
242
243    #[test]
244    fn transfer_keeps_an_explicit_profile() {
245        let t = parse_transfer("Transfer loopback/tty-a to inline[conference:835@wideband@public]")
246            .expect("parsed");
247        assert_eq!(t.name, "835");
248        assert_eq!(t.profile.as_deref(), Some("wideband"));
249    }
250
251    #[test]
252    fn transfer_elsewhere_is_not_a_conference() {
253        assert!(parse_transfer("Transfer sofia/internal/1000 to inline[park@default]").is_none());
254    }
255
256    #[test]
257    fn instance_lives_only_while_it_has_members() {
258        let mut reg = ConferenceRegistry::default();
259        reg.join("835", "aaaaaaaa-0000-0000-0000-000000000001", "a");
260        reg.join("835", "aaaaaaaa-0000-0000-0000-000000000001", "b");
261        assert_eq!(
262            reg.instance_for("835"),
263            Some("aaaaaaaa-0000-0000-0000-000000000001")
264        );
265
266        reg.leave("835", "a");
267        assert_eq!(
268            reg.instance_for("835"),
269            Some("aaaaaaaa-0000-0000-0000-000000000001")
270        );
271        reg.leave("835", "b");
272        assert_eq!(reg.instance_for("835"), None);
273
274        reg.join("835", "aaaaaaaa-0000-0000-0000-000000000002", "c");
275        assert_eq!(
276            reg.instance_for("835"),
277            Some("aaaaaaaa-0000-0000-0000-000000000002")
278        );
279    }
280
281    #[test]
282    fn members_are_listed_per_instance() {
283        let mut reg = ConferenceRegistry::default();
284        reg.join("835", "aaaaaaaa-0000-0000-0000-000000000001", "a");
285        reg.join("835", "aaaaaaaa-0000-0000-0000-000000000001", "b");
286        reg.join("844", "aaaaaaaa-0000-0000-0000-000000000003", "c");
287
288        let mut members: Vec<&str> = reg
289            .members("aaaaaaaa-0000-0000-0000-000000000001")
290            .collect();
291        members.sort_unstable();
292        assert_eq!(members, ["a", "b"]);
293        assert_eq!(
294            reg.members("aaaaaaaa-0000-0000-0000-000000000003")
295                .collect::<Vec<_>>(),
296            ["c"]
297        );
298    }
299}