1mod focus;
8mod member_data;
9mod member_state_key;
10
11use std::time::Duration;
12
13pub use focus::*;
14pub use member_data::*;
15pub use member_state_key::*;
16use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedDeviceId, room_version_rules::RedactionRules};
17use ruma_macros::{EventContent, StringEnum};
18use serde::{Deserialize, Serialize};
19
20use crate::{
21 PossiblyRedactedStateEventContent, PrivOwnedStr, RedactContent, RedactedStateEventContent,
22 StateEventType, StaticEventContent,
23};
24
25#[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)]
36#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = CallMemberStateKey, custom_redacted, custom_possibly_redacted)]
37#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
38#[serde(untagged)]
39pub enum CallMemberEventContent {
40 LegacyContent(LegacyMembershipContent),
43 SessionContent(SessionMembershipData),
46 Empty(EmptyMembershipData),
48}
49
50impl CallMemberEventContent {
51 pub fn new_legacy(memberships: Vec<LegacyMembershipData>) -> Self {
53 Self::LegacyContent(LegacyMembershipContent {
54 memberships, })
56 }
57
58 pub fn new(
70 application: Application,
71 device_id: OwnedDeviceId,
72 focus_active: ActiveFocus,
73 foci_preferred: Vec<Focus>,
74 created_ts: Option<MilliSecondsSinceUnixEpoch>,
75 expires: Option<Duration>,
76 ) -> Self {
77 Self::SessionContent(SessionMembershipData {
78 application,
79 device_id,
80 focus_active,
81 foci_preferred,
82 created_ts,
83 expires: expires.unwrap_or(Duration::from_secs(14_400)), })
85 }
86
87 pub fn new_empty(leave_reason: Option<LeaveReason>) -> Self {
89 Self::Empty(EmptyMembershipData { leave_reason })
90 }
91
92 pub fn active_memberships(
104 &self,
105 origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
106 ) -> Vec<MembershipData<'_>> {
107 match self {
108 CallMemberEventContent::LegacyContent(content) => content
109 .memberships
110 .iter()
111 .map(MembershipData::Legacy)
112 .filter(|m| !m.is_expired(origin_server_ts))
113 .collect(),
114 CallMemberEventContent::SessionContent(content) => {
115 vec![MembershipData::Session(content)]
116 .into_iter()
117 .filter(|m| !m.is_expired(origin_server_ts))
118 .collect()
119 }
120
121 CallMemberEventContent::Empty(_) => Vec::new(),
122 }
123 }
124
125 pub fn memberships(&self) -> Vec<MembershipData<'_>> {
128 match self {
129 CallMemberEventContent::LegacyContent(content) => {
130 content.memberships.iter().map(MembershipData::Legacy).collect()
131 }
132 CallMemberEventContent::SessionContent(content) => {
133 [content].map(MembershipData::Session).to_vec()
134 }
135 CallMemberEventContent::Empty(_) => Vec::new(),
136 }
137 }
138
139 pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) {
148 match self {
149 CallMemberEventContent::LegacyContent(content) => {
150 content.memberships.iter_mut().for_each(|m: &mut LegacyMembershipData| {
151 m.created_ts.get_or_insert(origin_server_ts);
152 });
153 }
154 CallMemberEventContent::SessionContent(m) => {
155 m.created_ts.get_or_insert(origin_server_ts);
156 }
157 _ => (),
158 }
159 }
160}
161
162#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
164#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
165pub struct EmptyMembershipData {
166 #[serde(skip_serializing_if = "Option::is_none")]
169 pub leave_reason: Option<LeaveReason>,
170}
171
172#[derive(Clone, StringEnum)]
178#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
179#[ruma_enum(rename_all(prefix = "m.", rule = "snake_case"))]
180pub enum LeaveReason {
181 LostConnection,
184 #[doc(hidden)]
185 _Custom(PrivOwnedStr),
186}
187
188impl RedactContent for CallMemberEventContent {
189 type Redacted = RedactedCallMemberEventContent;
190
191 fn redact(self, _rules: &RedactionRules) -> Self::Redacted {
192 RedactedCallMemberEventContent {}
193 }
194}
195
196pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent;
201
202impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent {
203 type StateKey = CallMemberStateKey;
204
205 fn event_type(&self) -> StateEventType {
206 StateEventType::CallMember
207 }
208}
209
210#[derive(Clone, Debug, Deserialize, Serialize)]
212#[allow(clippy::exhaustive_structs)]
213pub struct RedactedCallMemberEventContent {}
214
215impl RedactedStateEventContent for RedactedCallMemberEventContent {
216 type StateKey = CallMemberStateKey;
217
218 fn event_type(&self) -> StateEventType {
219 StateEventType::CallMember
220 }
221}
222
223impl StaticEventContent for RedactedCallMemberEventContent {
224 const TYPE: &'static str = CallMemberEventContent::TYPE;
225 type IsPrefix = <CallMemberEventContent as StaticEventContent>::IsPrefix;
226}
227
228impl From<RedactedCallMemberEventContent> for PossiblyRedactedCallMemberEventContent {
229 fn from(_value: RedactedCallMemberEventContent) -> Self {
230 Self::new_empty(None)
231 }
232}
233
234#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
236#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
237pub struct LegacyMembershipContent {
238 memberships: Vec<LegacyMembershipData>,
249}
250
251#[cfg(test)]
252mod tests {
253 use std::time::Duration;
254
255 use assert_matches2::assert_matches;
256 use js_int::{int, uint};
257 use ruma_common::{
258 MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedUserId, device_id,
259 owned_device_id, user_id,
260 };
261 use serde_json::{Value as JsonValue, from_value as from_json_value, json};
262
263 use super::{
264 CallMemberEventContent,
265 focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus},
266 member_data::{
267 Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData,
268 },
269 };
270 use crate::{
271 AnyStateEvent, StateEvent,
272 call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData},
273 rtc::notification::CallIntent,
274 };
275
276 fn create_call_member_legacy_event_content() -> CallMemberEventContent {
277 CallMemberEventContent::new_legacy(vec![LegacyMembershipData {
278 application: Application::Call(CallApplicationContent::new(
279 "123456".to_owned(),
280 CallScope::Room,
281 )),
282 device_id: owned_device_id!("ABCDE"),
283 expires: Duration::from_secs(3600),
284 foci_active: vec![Focus::Livekit(LivekitFocus {
285 alias: "1".to_owned(),
286 service_url: "https://livekit.com".to_owned(),
287 })],
288 membership_id: "0".to_owned(),
289 created_ts: None,
290 }])
291 }
292
293 fn create_call_member_event_content() -> CallMemberEventContent {
294 CallMemberEventContent::new(
295 Application::Call(CallApplicationContent::new("123456".to_owned(), CallScope::Room)),
296 owned_device_id!("ABCDE"),
297 ActiveFocus::Livekit(ActiveLivekitFocus {
298 focus_selection: FocusSelection::OldestMembership,
299 }),
300 vec![Focus::Livekit(LivekitFocus {
301 alias: "1".to_owned(),
302 service_url: "https://livekit.com".to_owned(),
303 })],
304 None,
305 Duration::from_secs(3600).into(), )
307 }
308
309 #[test]
310 fn serialize_call_member_event_content() {
311 let call_member_event = &json!({
312 "application": "m.call",
313 "call_id": "123456",
314 "scope": "m.room",
315 "device_id": "ABCDE",
316 "expires": 3_600_000, "foci_preferred": [
318 {
319 "livekit_alias": "1",
320 "livekit_service_url": "https://livekit.com",
321 "type": "livekit"
322 }
323 ],
324 "focus_active":{
325 "type":"livekit",
326 "focus_selection":"oldest_membership"
327 }
328 });
329 assert_eq!(
330 call_member_event,
331 &serde_json::to_value(create_call_member_event_content()).unwrap()
332 );
333
334 let empty_call_member_event = &json!({});
335 assert_eq!(
336 empty_call_member_event,
337 &serde_json::to_value(CallMemberEventContent::Empty(EmptyMembershipData {
338 leave_reason: None
339 }))
340 .unwrap()
341 );
342 }
343
344 #[test]
345 fn serialize_legacy_call_member_event_content() {
346 let call_member_event = &json!({
347 "memberships": [
348 {
349 "application": "m.call",
350 "call_id": "123456",
351 "scope": "m.room",
352 "device_id": "ABCDE",
353 "expires": 3_600_000,
354 "foci_active": [
355 {
356 "livekit_alias": "1",
357 "livekit_service_url": "https://livekit.com",
358 "type": "livekit"
359 }
360 ],
361 "membershipID": "0"
362 }
363 ]
364 });
365
366 assert_eq!(
367 call_member_event,
368 &serde_json::to_value(create_call_member_legacy_event_content()).unwrap()
369 );
370 }
371 #[test]
372 fn deserialize_call_member_event_content() {
373 let call_member_ev = CallMemberEventContent::new(
374 Application::Call(CallApplicationContent::new("123456".to_owned(), CallScope::Room)),
375 owned_device_id!("THIS_DEVICE"),
376 ActiveFocus::Livekit(ActiveLivekitFocus {
377 focus_selection: FocusSelection::OldestMembership,
378 }),
379 vec![Focus::Livekit(LivekitFocus {
380 alias: "room1".to_owned(),
381 service_url: "https://livekit1.com".to_owned(),
382 })],
383 None,
384 None,
385 );
386
387 let call_member_ev_json = json!({
388 "application": "m.call",
389 "call_id": "123456",
390 "scope": "m.room",
391 "expires": 14_400_000, "device_id": "THIS_DEVICE",
393 "focus_active":{
394 "type": "livekit",
395 "focus_selection": "oldest_membership"
396 },
397 "foci_preferred": [
398 {
399 "livekit_alias": "room1",
400 "livekit_service_url": "https://livekit1.com",
401 "type": "livekit"
402 }
403 ],
404 });
405
406 let ev_content: CallMemberEventContent =
407 serde_json::from_value(call_member_ev_json).unwrap();
408 assert_eq!(
409 serde_json::to_string(&ev_content).unwrap(),
410 serde_json::to_string(&call_member_ev).unwrap()
411 );
412 let empty = CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None });
413 assert_eq!(
414 serde_json::to_string(&json!({})).unwrap(),
415 serde_json::to_string(&empty).unwrap()
416 );
417 }
418
419 #[test]
420 #[cfg(feature = "unstable-msc4075")]
421 fn deserialize_event_with_call_intent() {
422 let call_member_ev = CallMemberEventContent::new(
423 Application::Call(CallApplicationContent {
424 call_id: "".to_owned(),
425 scope: CallScope::Room,
426 call_intent: Some(CallIntent::Audio),
427 }),
428 owned_device_id!("THIS_DEVICE"),
429 ActiveFocus::Livekit(ActiveLivekitFocus {
430 focus_selection: FocusSelection::OldestMembership,
431 }),
432 vec![Focus::Livekit(LivekitFocus {
433 alias: "room1".to_owned(),
434 service_url: "https://livekit1.com".to_owned(),
435 })],
436 None,
437 None,
438 );
439
440 let json = json!({
441 "application": "m.call",
442 "call_id": "",
443 "scope": "m.room",
444 "m.call.intent": "audio",
445 "device_id": "THIS_DEVICE",
446 "foci_preferred": [
447 {
448 "type": "livekit",
449 "livekit_alias": "room1",
450 "livekit_service_url": "https://livekit1.com"
451 }
452 ],
453 "focus_active": {
454 "type": "livekit",
455 "focus_selection": "oldest_membership"
456 },
457 "expires": 14_400_000
458 });
459
460 let ev_content: CallMemberEventContent = serde_json::from_value(json).unwrap();
461 assert_eq!(
462 serde_json::to_string(&ev_content).unwrap(),
463 serde_json::to_string(&call_member_ev).unwrap()
464 );
465 }
466
467 #[test]
468 #[cfg(feature = "unstable-msc4075")]
469 fn deserialize_application() {
470 let test_cases = vec![
471 (
472 Application::Call(CallApplicationContent {
473 call_id: "".to_owned(),
474 scope: CallScope::Room,
475 call_intent: None,
476 }),
477 json!({
478 "application": "m.call",
479 "call_id": "",
480 "scope": "m.room",
481 }),
482 ),
483 (
484 Application::Call(CallApplicationContent {
485 call_id: "".to_owned(),
486 scope: CallScope::Room,
487 call_intent: Some(CallIntent::Audio),
488 }),
489 json!({
490 "application": "m.call",
491 "call_id": "",
492 "scope": "m.room",
493 "m.call.intent": "audio"
494 }),
495 ),
496 (
497 Application::Call(CallApplicationContent {
498 call_id: "xxxx".to_owned(),
499 scope: CallScope::User,
500 call_intent: Some(CallIntent::Video),
501 }),
502 json!({
503 "application": "m.call",
504 "call_id": "xxxx",
505 "scope": "m.user",
506 "m.call.intent": "video"
507 }),
508 ),
509 ];
510
511 for (model, jon) in test_cases {
512 let app: Application = serde_json::from_value(jon).unwrap();
513 assert_eq!(
514 serde_json::to_string(&app).unwrap(),
515 serde_json::to_string(&model).unwrap()
516 );
517 }
518 }
519
520 #[test]
521 fn deserialize_legacy_call_member_event_content() {
522 let call_member_ev = CallMemberEventContent::new_legacy(vec![
523 LegacyMembershipData {
524 application: Application::Call(CallApplicationContent::new(
525 "123456".to_owned(),
526 CallScope::Room,
527 )),
528 device_id: owned_device_id!("THIS_DEVICE"),
529 expires: Duration::from_secs(3600),
530 foci_active: vec![Focus::Livekit(LivekitFocus {
531 alias: "room1".to_owned(),
532 service_url: "https://livekit1.com".to_owned(),
533 })],
534 membership_id: "0".to_owned(),
535 created_ts: None,
536 },
537 LegacyMembershipData {
538 application: Application::Call(CallApplicationContent::new(
539 "".to_owned(),
540 CallScope::Room,
541 )),
542 device_id: owned_device_id!("OTHER_DEVICE"),
543 expires: Duration::from_secs(3600),
544 foci_active: vec![Focus::Livekit(LivekitFocus {
545 alias: "room2".to_owned(),
546 service_url: "https://livekit2.com".to_owned(),
547 })],
548 membership_id: "0".to_owned(),
549 created_ts: None,
550 },
551 ]);
552
553 let call_member_ev_json = json!({
554 "memberships": [
555 {
556 "application": "m.call",
557 "call_id": "123456",
558 "scope": "m.room",
559 "device_id": "THIS_DEVICE",
560 "expires": 3_600_000,
561 "foci_active": [
562 {
563 "livekit_alias": "room1",
564 "livekit_service_url": "https://livekit1.com",
565 "type": "livekit"
566 }
567 ],
568 "membershipID": "0",
569 },
570 {
571 "application": "m.call",
572 "call_id": "",
573 "scope": "m.room",
574 "device_id": "OTHER_DEVICE",
575 "expires": 3_600_000,
576 "foci_active": [
577 {
578 "livekit_alias": "room2",
579 "livekit_service_url": "https://livekit2.com",
580 "type": "livekit"
581 }
582 ],
583 "membershipID": "0"
584 }
585 ]
586 });
587
588 let ev_content: CallMemberEventContent =
589 serde_json::from_value(call_member_ev_json).unwrap();
590 assert_eq!(
591 serde_json::to_string(&ev_content).unwrap(),
592 serde_json::to_string(&call_member_ev).unwrap()
593 );
594 }
595
596 fn member_event_json(state_key: &str) -> JsonValue {
597 json!({
598 "content":{
599 "expires": 3_600_000, "application": "m.call",
601 "call_id": "",
602 "scope": "m.room",
603 "device_id": "THIS_DEVICE",
604 "focus_active":{
605 "type": "livekit",
606 "focus_selection": "oldest_membership"
607 },
608 "foci_preferred": [
609 {
610 "livekit_alias": "room1",
611 "livekit_service_url": "https://livekit1.com",
612 "type": "livekit"
613 }
614 ],
615 },
616 "type": "m.call.member",
617 "origin_server_ts": 111,
618 "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc",
619 "room_id": "!1234:example.org",
620 "sender": "@user:example.org",
621 "state_key": state_key,
622 "unsigned":{
623 "age":10,
624 "prev_content": {},
625 "prev_sender":"@user:example.org",
626 }
627 })
628 }
629
630 fn deserialize_member_event_helper(state_key: &str) {
631 let ev = member_event_json(state_key);
632
633 assert_matches!(
634 from_json_value(ev),
635 Ok(AnyStateEvent::CallMember(StateEvent::Original(member_event)))
636 );
637
638 let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap();
639 let sender = OwnedUserId::try_from("@user:example.org").unwrap();
640 let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap();
641 assert_eq!(member_event.state_key.as_ref(), state_key);
642 assert_eq!(member_event.event_id, event_id);
643 assert_eq!(member_event.sender, sender);
644 assert_eq!(member_event.room_id, room_id);
645 assert_eq!(member_event.origin_server_ts.0, uint!(111));
646 let membership = SessionMembershipData {
647 application: Application::Call(CallApplicationContent::new(
648 "".to_owned(),
649 CallScope::Room,
650 )),
651 device_id: owned_device_id!("THIS_DEVICE"),
652 foci_preferred: [Focus::Livekit(LivekitFocus {
653 alias: "room1".to_owned(),
654 service_url: "https://livekit1.com".to_owned(),
655 })]
656 .to_vec(),
657 focus_active: ActiveFocus::Livekit(ActiveLivekitFocus {
658 focus_selection: FocusSelection::OldestMembership,
659 }),
660 created_ts: None,
661 expires: Duration::from_secs(3600),
662 };
663 assert_eq!(
664 member_event.content,
665 CallMemberEventContent::SessionContent(membership.clone())
666 );
667
668 assert_eq!(
670 member_event.content.active_memberships(None)[0],
671 MembershipData::Session(&membership)
672 );
673 assert_eq!(member_event.unsigned.age, Some(int!(10)));
674 assert_eq!(
675 member_event.unsigned.prev_content.unwrap(),
676 CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }),
677 );
678 }
679
680 #[test]
681 fn deserialize_member_event() {
682 deserialize_member_event_helper("@user:example.org");
683 }
684
685 #[test]
686 fn deserialize_member_event_with_scoped_state_key_prefixed() {
687 deserialize_member_event_helper("_@user:example.org_THIS_DEVICE_m.call");
688 }
689
690 #[test]
691 fn deserialize_member_event_with_scoped_state_key_unprefixed() {
692 deserialize_member_event_helper("@user:example.org_THIS_DEVICE_m.call");
693 }
694
695 fn timestamps()
696 -> (MilliSecondsSinceUnixEpoch, MilliSecondsSinceUnixEpoch, MilliSecondsSinceUnixEpoch) {
697 let now = MilliSecondsSinceUnixEpoch::now();
698 let one_second_ago =
699 now.to_system_time().unwrap().checked_sub(Duration::from_secs(1)).unwrap();
700 let two_hours_ago =
701 now.to_system_time().unwrap().checked_sub(Duration::from_secs(60 * 60 * 2)).unwrap();
702 (
703 now,
704 MilliSecondsSinceUnixEpoch::from_system_time(one_second_ago).unwrap(),
705 MilliSecondsSinceUnixEpoch::from_system_time(two_hours_ago).unwrap(),
706 )
707 }
708
709 #[test]
710 fn legacy_memberships_do_expire() {
711 let content_legacy = create_call_member_legacy_event_content();
712 let (now, one_second_ago, two_hours_ago) = timestamps();
713
714 assert_eq!(
715 content_legacy.active_memberships(Some(one_second_ago)),
716 content_legacy.memberships()
717 );
718 assert_eq!(content_legacy.active_memberships(Some(now)), content_legacy.memberships());
719 assert_eq!(
720 content_legacy.active_memberships(Some(two_hours_ago)),
721 vec![] as Vec<MembershipData<'_>>
722 );
723 }
724
725 #[test]
726 fn session_membership_does_expire() {
727 let content = create_call_member_event_content();
728 let (now, one_second_ago, two_hours_ago) = timestamps();
729
730 assert_eq!(content.active_memberships(Some(now)), content.memberships());
731 assert_eq!(content.active_memberships(Some(one_second_ago)), content.memberships());
732 assert_eq!(
733 content.active_memberships(Some(two_hours_ago)),
734 vec![] as Vec<MembershipData<'_>>
735 );
736 }
737
738 #[test]
739 fn set_created_ts() {
740 let mut content_now = create_call_member_legacy_event_content();
741 let mut content_two_hours_ago = create_call_member_legacy_event_content();
742 let mut content_one_second_ago = create_call_member_legacy_event_content();
743 let (now, one_second_ago, two_hours_ago) = timestamps();
744
745 content_now.set_created_ts_if_none(now);
746 content_one_second_ago.set_created_ts_if_none(one_second_ago);
747 content_two_hours_ago.set_created_ts_if_none(two_hours_ago);
748 assert_eq!(content_now.active_memberships(None), content_now.memberships());
749
750 assert_eq!(
751 content_two_hours_ago.active_memberships(None),
752 vec![] as Vec<MembershipData<'_>>
753 );
754 assert_eq!(
755 content_one_second_ago.active_memberships(None),
756 content_one_second_ago.memberships()
757 );
758
759 content_two_hours_ago.set_created_ts_if_none(one_second_ago);
761 assert_eq!(
763 content_two_hours_ago.active_memberships(None),
764 vec![] as Vec<MembershipData<'_>>
765 );
766 }
767
768 #[test]
769 fn test_parse_rtc_member_event_key() {
770 assert!(from_json_value::<AnyStateEvent>(member_event_json("abc")).is_err());
771 assert!(from_json_value::<AnyStateEvent>(member_event_json("@nocolon")).is_err());
772 assert!(from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:")).is_err());
773 assert!(
774 from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:_suffix")).is_err()
775 );
776
777 let user_id = user_id!("@username:example.org").as_str();
778 let device_id = device_id!("VALID_DEVICE_ID").as_str();
779
780 let parse_result = from_json_value::<AnyStateEvent>(member_event_json(user_id));
781 assert_matches!(parse_result, Ok(_));
782 assert_matches!(
783 from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_{device_id}"))),
784 Ok(_)
785 );
786
787 assert_matches!(
788 from_json_value::<AnyStateEvent>(member_event_json(&format!(
789 "{user_id}:invalid_suffix"
790 ))),
791 Err(_)
792 );
793
794 assert_matches!(
795 from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}"))),
796 Err(_)
797 );
798
799 assert_matches!(
800 from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}_{device_id}"))),
801 Ok(_)
802 );
803
804 assert_matches!(
805 from_json_value::<AnyStateEvent>(member_event_json(&format!(
806 "_{user_id}:invalid_suffix"
807 ))),
808 Err(_)
809 );
810 assert_matches!(
811 from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_"))),
812 Err(_)
813 );
814 }
815}