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_matches::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 use strass::assert_let;
263
264 use super::{
265 CallMemberEventContent,
266 focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus},
267 member_data::{
268 Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData,
269 },
270 };
271 use crate::{
272 AnyStateEvent, StateEvent,
273 call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData},
274 rtc::notification::CallIntent,
275 };
276
277 fn create_call_member_legacy_event_content() -> CallMemberEventContent {
278 CallMemberEventContent::new_legacy(vec![LegacyMembershipData {
279 application: Application::Call(CallApplicationContent::new(
280 "123456".to_owned(),
281 CallScope::Room,
282 )),
283 device_id: owned_device_id!("ABCDE"),
284 expires: Duration::from_secs(3600),
285 foci_active: vec![Focus::Livekit(LivekitFocus {
286 alias: "1".to_owned(),
287 service_url: "https://livekit.com".to_owned(),
288 })],
289 membership_id: "0".to_owned(),
290 created_ts: None,
291 }])
292 }
293
294 fn create_call_member_event_content() -> CallMemberEventContent {
295 CallMemberEventContent::new(
296 Application::Call(CallApplicationContent::new("123456".to_owned(), CallScope::Room)),
297 owned_device_id!("ABCDE"),
298 ActiveFocus::Livekit(ActiveLivekitFocus {
299 focus_selection: FocusSelection::OldestMembership,
300 }),
301 vec![Focus::Livekit(LivekitFocus {
302 alias: "1".to_owned(),
303 service_url: "https://livekit.com".to_owned(),
304 })],
305 None,
306 Duration::from_secs(3600).into(), )
308 }
309
310 #[test]
311 fn serialize_call_member_event_content() {
312 let call_member_event = &json!({
313 "application": "m.call",
314 "call_id": "123456",
315 "scope": "m.room",
316 "device_id": "ABCDE",
317 "expires": 3_600_000, "foci_preferred": [
319 {
320 "livekit_alias": "1",
321 "livekit_service_url": "https://livekit.com",
322 "type": "livekit"
323 }
324 ],
325 "focus_active":{
326 "type":"livekit",
327 "focus_selection":"oldest_membership"
328 }
329 });
330 assert_eq!(
331 call_member_event,
332 &serde_json::to_value(create_call_member_event_content()).unwrap()
333 );
334
335 let empty_call_member_event = &json!({});
336 assert_eq!(
337 empty_call_member_event,
338 &serde_json::to_value(CallMemberEventContent::Empty(EmptyMembershipData {
339 leave_reason: None
340 }))
341 .unwrap()
342 );
343 }
344
345 #[test]
346 fn serialize_legacy_call_member_event_content() {
347 let call_member_event = &json!({
348 "memberships": [
349 {
350 "application": "m.call",
351 "call_id": "123456",
352 "scope": "m.room",
353 "device_id": "ABCDE",
354 "expires": 3_600_000,
355 "foci_active": [
356 {
357 "livekit_alias": "1",
358 "livekit_service_url": "https://livekit.com",
359 "type": "livekit"
360 }
361 ],
362 "membershipID": "0"
363 }
364 ]
365 });
366
367 assert_eq!(
368 call_member_event,
369 &serde_json::to_value(create_call_member_legacy_event_content()).unwrap()
370 );
371 }
372 #[test]
373 fn deserialize_call_member_event_content() {
374 let call_member_ev = CallMemberEventContent::new(
375 Application::Call(CallApplicationContent::new("123456".to_owned(), CallScope::Room)),
376 owned_device_id!("THIS_DEVICE"),
377 ActiveFocus::Livekit(ActiveLivekitFocus {
378 focus_selection: FocusSelection::OldestMembership,
379 }),
380 vec![Focus::Livekit(LivekitFocus {
381 alias: "room1".to_owned(),
382 service_url: "https://livekit1.com".to_owned(),
383 })],
384 None,
385 None,
386 );
387
388 let call_member_ev_json = json!({
389 "application": "m.call",
390 "call_id": "123456",
391 "scope": "m.room",
392 "expires": 14_400_000, "device_id": "THIS_DEVICE",
394 "focus_active":{
395 "type": "livekit",
396 "focus_selection": "oldest_membership"
397 },
398 "foci_preferred": [
399 {
400 "livekit_alias": "room1",
401 "livekit_service_url": "https://livekit1.com",
402 "type": "livekit"
403 }
404 ],
405 });
406
407 let ev_content: CallMemberEventContent =
408 serde_json::from_value(call_member_ev_json).unwrap();
409 assert_eq!(
410 serde_json::to_string(&ev_content).unwrap(),
411 serde_json::to_string(&call_member_ev).unwrap()
412 );
413 let empty = CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None });
414 assert_eq!(
415 serde_json::to_string(&json!({})).unwrap(),
416 serde_json::to_string(&empty).unwrap()
417 );
418 }
419
420 #[test]
421 #[cfg(feature = "unstable-msc4075")]
422 fn deserialize_event_with_call_intent() {
423 let call_member_ev = CallMemberEventContent::new(
424 Application::Call(CallApplicationContent {
425 call_id: "".to_owned(),
426 scope: CallScope::Room,
427 call_intent: Some(CallIntent::Audio),
428 }),
429 owned_device_id!("THIS_DEVICE"),
430 ActiveFocus::Livekit(ActiveLivekitFocus {
431 focus_selection: FocusSelection::OldestMembership,
432 }),
433 vec![Focus::Livekit(LivekitFocus {
434 alias: "room1".to_owned(),
435 service_url: "https://livekit1.com".to_owned(),
436 })],
437 None,
438 None,
439 );
440
441 let json = json!({
442 "application": "m.call",
443 "call_id": "",
444 "scope": "m.room",
445 "m.call.intent": "audio",
446 "device_id": "THIS_DEVICE",
447 "foci_preferred": [
448 {
449 "type": "livekit",
450 "livekit_alias": "room1",
451 "livekit_service_url": "https://livekit1.com"
452 }
453 ],
454 "focus_active": {
455 "type": "livekit",
456 "focus_selection": "oldest_membership"
457 },
458 "expires": 14_400_000
459 });
460
461 let ev_content: CallMemberEventContent = serde_json::from_value(json).unwrap();
462 assert_eq!(
463 serde_json::to_string(&ev_content).unwrap(),
464 serde_json::to_string(&call_member_ev).unwrap()
465 );
466 }
467
468 #[test]
469 #[cfg(feature = "unstable-msc4075")]
470 fn deserialize_application() {
471 let test_cases = vec![
472 (
473 Application::Call(CallApplicationContent {
474 call_id: "".to_owned(),
475 scope: CallScope::Room,
476 call_intent: None,
477 }),
478 json!({
479 "application": "m.call",
480 "call_id": "",
481 "scope": "m.room",
482 }),
483 ),
484 (
485 Application::Call(CallApplicationContent {
486 call_id: "".to_owned(),
487 scope: CallScope::Room,
488 call_intent: Some(CallIntent::Audio),
489 }),
490 json!({
491 "application": "m.call",
492 "call_id": "",
493 "scope": "m.room",
494 "m.call.intent": "audio"
495 }),
496 ),
497 (
498 Application::Call(CallApplicationContent {
499 call_id: "xxxx".to_owned(),
500 scope: CallScope::User,
501 call_intent: Some(CallIntent::Video),
502 }),
503 json!({
504 "application": "m.call",
505 "call_id": "xxxx",
506 "scope": "m.user",
507 "m.call.intent": "video"
508 }),
509 ),
510 ];
511
512 for (model, jon) in test_cases {
513 let app: Application = serde_json::from_value(jon).unwrap();
514 assert_eq!(
515 serde_json::to_string(&app).unwrap(),
516 serde_json::to_string(&model).unwrap()
517 );
518 }
519 }
520
521 #[test]
522 fn deserialize_legacy_call_member_event_content() {
523 let call_member_ev = CallMemberEventContent::new_legacy(vec![
524 LegacyMembershipData {
525 application: Application::Call(CallApplicationContent::new(
526 "123456".to_owned(),
527 CallScope::Room,
528 )),
529 device_id: owned_device_id!("THIS_DEVICE"),
530 expires: Duration::from_secs(3600),
531 foci_active: vec![Focus::Livekit(LivekitFocus {
532 alias: "room1".to_owned(),
533 service_url: "https://livekit1.com".to_owned(),
534 })],
535 membership_id: "0".to_owned(),
536 created_ts: None,
537 },
538 LegacyMembershipData {
539 application: Application::Call(CallApplicationContent::new(
540 "".to_owned(),
541 CallScope::Room,
542 )),
543 device_id: owned_device_id!("OTHER_DEVICE"),
544 expires: Duration::from_secs(3600),
545 foci_active: vec![Focus::Livekit(LivekitFocus {
546 alias: "room2".to_owned(),
547 service_url: "https://livekit2.com".to_owned(),
548 })],
549 membership_id: "0".to_owned(),
550 created_ts: None,
551 },
552 ]);
553
554 let call_member_ev_json = json!({
555 "memberships": [
556 {
557 "application": "m.call",
558 "call_id": "123456",
559 "scope": "m.room",
560 "device_id": "THIS_DEVICE",
561 "expires": 3_600_000,
562 "foci_active": [
563 {
564 "livekit_alias": "room1",
565 "livekit_service_url": "https://livekit1.com",
566 "type": "livekit"
567 }
568 ],
569 "membershipID": "0",
570 },
571 {
572 "application": "m.call",
573 "call_id": "",
574 "scope": "m.room",
575 "device_id": "OTHER_DEVICE",
576 "expires": 3_600_000,
577 "foci_active": [
578 {
579 "livekit_alias": "room2",
580 "livekit_service_url": "https://livekit2.com",
581 "type": "livekit"
582 }
583 ],
584 "membershipID": "0"
585 }
586 ]
587 });
588
589 let ev_content: CallMemberEventContent =
590 serde_json::from_value(call_member_ev_json).unwrap();
591 assert_eq!(
592 serde_json::to_string(&ev_content).unwrap(),
593 serde_json::to_string(&call_member_ev).unwrap()
594 );
595 }
596
597 fn member_event_json(state_key: &str) -> JsonValue {
598 json!({
599 "content":{
600 "expires": 3_600_000, "application": "m.call",
602 "call_id": "",
603 "scope": "m.room",
604 "device_id": "THIS_DEVICE",
605 "focus_active":{
606 "type": "livekit",
607 "focus_selection": "oldest_membership"
608 },
609 "foci_preferred": [
610 {
611 "livekit_alias": "room1",
612 "livekit_service_url": "https://livekit1.com",
613 "type": "livekit"
614 }
615 ],
616 },
617 "type": "m.call.member",
618 "origin_server_ts": 111,
619 "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc",
620 "room_id": "!1234:example.org",
621 "sender": "@user:example.org",
622 "state_key": state_key,
623 "unsigned":{
624 "age":10,
625 "prev_content": {},
626 "prev_sender":"@user:example.org",
627 }
628 })
629 }
630
631 fn deserialize_member_event_helper(state_key: &str) {
632 let ev = member_event_json(state_key);
633
634 assert_let!(
635 Ok(AnyStateEvent::CallMember(StateEvent::Original(member_event))) = from_json_value(ev)
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}