feat: allow clients to specify whether a call has video initially or not (#7740)

This commit is contained in:
Casper Zandbergen
2026-02-04 17:49:32 +01:00
committed by GitHub
parent d6bce56d18
commit 63bf4c4f33
12 changed files with 69 additions and 79 deletions

View File

@@ -1242,9 +1242,12 @@ uint32_t dc_init_webxdc_integration (dc_context_t* context, uint32_t c
* This needs to be a one-to-one chat. * This needs to be a one-to-one chat.
* @param place_call_info any data that other devices receive * @param place_call_info any data that other devices receive
* in #DC_EVENT_INCOMING_CALL. * in #DC_EVENT_INCOMING_CALL.
* @param has_video Whether the call has video initially.
* This allows the recipient's client to adjust incoming call UX.
* A call can be upgraded to include video later.
* @return ID of the system message announcing the call. * @return ID of the system message announcing the call.
*/ */
uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info); uint32_t dc_place_outgoing_call (dc_context_t* context, uint32_t chat_id, const char* place_call_info, int has_video);
/** /**

View File

@@ -1181,6 +1181,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
context: *mut dc_context_t, context: *mut dc_context_t,
chat_id: u32, chat_id: u32,
place_call_info: *const libc::c_char, place_call_info: *const libc::c_char,
has_video: bool,
) -> u32 { ) -> u32 {
if context.is_null() || chat_id == 0 { if context.is_null() || chat_id == 0 {
eprintln!("ignoring careless call to dc_place_outgoing_call()"); eprintln!("ignoring careless call to dc_place_outgoing_call()");
@@ -1190,7 +1191,7 @@ pub unsafe extern "C" fn dc_place_outgoing_call(
let chat_id = ChatId::new(chat_id); let chat_id = ChatId::new(chat_id);
let place_call_info = to_string_lossy(place_call_info); let place_call_info = to_string_lossy(place_call_info);
block_on(ctx.place_outgoing_call(chat_id, place_call_info)) block_on(ctx.place_outgoing_call(chat_id, place_call_info, has_video))
.context("Failed to place call") .context("Failed to place call")
.log_err(ctx) .log_err(ctx)
.map(|msg_id| msg_id.to_u32()) .map(|msg_id| msg_id.to_u32())

View File

@@ -2167,10 +2167,11 @@ impl CommandApi {
account_id: u32, account_id: u32,
chat_id: u32, chat_id: u32,
place_call_info: String, place_call_info: String,
has_video: bool,
) -> Result<u32> { ) -> Result<u32> {
let ctx = self.get_context(account_id).await?; let ctx = self.get_context(account_id).await?;
let msg_id = ctx let msg_id = ctx
.place_outgoing_call(ChatId::new(chat_id), place_call_info) .place_outgoing_call(ChatId::new(chat_id), place_call_info, has_video)
.await?; .await?;
Ok(msg_id.to_u32()) Ok(msg_id.to_u32())
} }

View File

@@ -1,6 +1,6 @@
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use deltachat::calls::{call_state, sdp_has_video, CallState}; use deltachat::calls::{call_state, CallState};
use deltachat::context::Context; use deltachat::context::Context;
use deltachat::message::MsgId; use deltachat::message::MsgId;
use serde::Serialize; use serde::Serialize;
@@ -15,7 +15,7 @@ pub struct JsonrpcCallInfo {
/// even if incoming call event was missed. /// even if incoming call event was missed.
pub sdp_offer: String, pub sdp_offer: String,
/// True if SDP offer has a video. /// True if the call is started as a video call.
pub has_video: bool, pub has_video: bool,
/// Call state. /// Call state.
@@ -30,7 +30,7 @@ impl JsonrpcCallInfo {
format!("Attempting to get call state of non-call message {msg_id}") format!("Attempting to get call state of non-call message {msg_id}")
})?; })?;
let sdp_offer = call_info.place_call_info.clone(); let sdp_offer = call_info.place_call_info.clone();
let has_video = sdp_has_video(&sdp_offer).unwrap_or_default(); let has_video = call_info.has_video_initially();
let state = JsonrpcCallState::from_msg_id(context, msg_id).await?; let state = JsonrpcCallState::from_msg_id(context, msg_id).await?;
Ok(JsonrpcCallInfo { Ok(JsonrpcCallInfo {

View File

@@ -303,7 +303,7 @@ class Chat:
f.flush() f.flush()
self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name}) self._rpc.send_msg(self.account.id, self.id, {"viewtype": ViewType.VCARD, "file": f.name})
def place_outgoing_call(self, place_call_info: str) -> Message: def place_outgoing_call(self, place_call_info: str, has_video_initially: bool) -> Message:
"""Starts an outgoing call.""" """Starts an outgoing call."""
msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info) msg_id = self._rpc.place_outgoing_call(self.account.id, self.id, place_call_info, has_video_initially)
return Message(self.account, msg_id) return Message(self.account, msg_id)

View File

@@ -10,15 +10,15 @@ def test_calls(acfactory) -> None:
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
bob.create_chat(alice) # Accept the chat so incoming call causes a notification. bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info) outgoing_call_message = alice_chat_bob.place_outgoing_call(place_call_info, has_video_initially=True)
assert outgoing_call_message.get_call_info().state.kind == "Alerting" assert outgoing_call_message.get_call_info().state.kind == "Alerting"
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info assert incoming_call_event.place_call_info == place_call_info
assert not incoming_call_event.has_video # Cannot be parsed as SDP, so false by default assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id) incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().state.kind == "Alerting" assert incoming_call_message.get_call_info().state.kind == "Alerting"
assert not incoming_call_message.get_call_info().has_video assert incoming_call_message.get_call_info().has_video
incoming_call_message.accept_incoming_call(accept_call_info) incoming_call_message.accept_incoming_call(accept_call_info)
assert incoming_call_message.get_call_info().sdp_offer == place_call_info assert incoming_call_message.get_call_info().sdp_offer == place_call_info
@@ -41,46 +41,38 @@ def test_video_call(acfactory) -> None:
# #
# `s=` cannot be empty according to RFC 3264, # `s=` cannot be empty according to RFC 3264,
# so it is more clear as `s=-`. # so it is more clear as `s=-`.
place_call_info = """v=0\r
o=alice 2890844526 2890844526 IN IP6 2001:db8::3\r
s=-\r
c=IN IP6 2001:db8::3\r
t=0 0\r
a=group:BUNDLE foo bar\r
\r
m=audio 10000 RTP/AVP 0 8 97\r
b=AS:200\r
a=mid:foo\r
a=rtcp-mux\r
a=rtpmap:0 PCMU/8000\r
a=rtpmap:8 PCMA/8000\r
a=rtpmap:97 iLBC/8000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
\r
m=video 10002 RTP/AVP 31 32\r
b=AS:1000\r
a=mid:bar\r
a=rtcp-mux\r
a=rtpmap:31 H261/90000\r
a=rtpmap:32 MPV/90000\r
a=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r
"""
alice, bob = acfactory.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification. bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob") alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat() alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call(place_call_info) alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == place_call_info assert incoming_call_event.place_call_info == "offer"
assert incoming_call_event.has_video assert incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id) incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert incoming_call_message.get_call_info().has_video assert incoming_call_message.get_call_info().has_video
def test_audio_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2)
bob.create_chat(alice) # Accept the chat so incoming call causes a notification.
alice_contact_bob = alice.create_contact(bob, "Bob")
alice_chat_bob = alice_contact_bob.create_chat()
alice_chat_bob.place_outgoing_call("offer", has_video_initially=False)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
assert incoming_call_event.place_call_info == "offer"
assert not incoming_call_event.has_video
incoming_call_message = Message(bob, incoming_call_event.msg_id)
assert not incoming_call_message.get_call_info().has_video
def test_ice_servers(acfactory) -> None: def test_ice_servers(acfactory) -> None:
alice = acfactory.get_online_account() alice = acfactory.get_online_account()
@@ -92,7 +84,7 @@ def test_no_contact_request_call(acfactory) -> None:
alice, bob = acfactory.get_online_accounts(2) alice, bob = acfactory.get_online_accounts(2)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer") alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!") alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive # Notification for "Hello!" message should arrive
@@ -119,7 +111,7 @@ def test_who_can_call_me_nobody(acfactory) -> None:
bob.create_chat(alice) bob.create_chat(alice)
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer") alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
alice_chat_bob.send_text("Hello!") alice_chat_bob.send_text("Hello!")
# Notification for "Hello!" message should arrive # Notification for "Hello!" message should arrive
@@ -144,7 +136,7 @@ def test_who_can_call_me_everybody(acfactory) -> None:
bob.set_config("who_can_call_me", "0") bob.set_config("who_can_call_me", "0")
alice_chat_bob = alice.create_chat(bob) alice_chat_bob = alice.create_chat(bob)
alice_chat_bob.place_outgoing_call("offer") alice_chat_bob.place_outgoing_call("offer", has_video_initially=True)
incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL) incoming_call_event = bob.wait_for_event(EventType.INCOMING_CALL)
incoming_call_message = Message(bob, incoming_call_event.msg_id) incoming_call_message = Message(bob, incoming_call_event.msg_id)

View File

@@ -19,9 +19,7 @@ use crate::tools::{normalize_text, time};
use anyhow::{Context as _, Result, ensure}; use anyhow::{Context as _, Result, ensure};
use deltachat_derive::{FromSql, ToSql}; use deltachat_derive::{FromSql, ToSql};
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use sdp::SessionDescription;
use serde::Serialize; use serde::Serialize;
use std::io::Cursor;
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::Duration;
use tokio::task; use tokio::task;
@@ -126,6 +124,14 @@ impl CallInfo {
self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP) self.msg.param.exists(CALL_ACCEPTED_TIMESTAMP)
} }
/// Returns true if the call is started as a video call.
pub fn has_video_initially(&self) -> bool {
self.msg
.param
.get_bool(Param::WebrtcHasVideoInitially)
.unwrap_or(false)
}
/// Returns true if the call is missed /// Returns true if the call is missed
/// because the caller canceled it /// because the caller canceled it
/// explicitly before ringing stopped. /// explicitly before ringing stopped.
@@ -185,6 +191,7 @@ impl Context {
&self, &self,
chat_id: ChatId, chat_id: ChatId,
place_call_info: String, place_call_info: String,
has_video_initially: bool,
) -> Result<MsgId> { ) -> Result<MsgId> {
let chat = Chat::load_from_db(self, chat_id).await?; let chat = Chat::load_from_db(self, chat_id).await?;
ensure!( ensure!(
@@ -199,6 +206,8 @@ impl Context {
..Default::default() ..Default::default()
}; };
call.param.set(Param::WebrtcRoom, &place_call_info); call.param.set(Param::WebrtcRoom, &place_call_info);
call.param
.set_int(Param::WebrtcHasVideoInitially, has_video_initially.into());
call.id = send_msg(self, chat_id, &mut call).await?; call.id = send_msg(self, chat_id, &mut call).await?;
let wait = RINGING_SECONDS; let wait = RINGING_SECONDS;
@@ -344,13 +353,6 @@ impl Context {
} else { } else {
call.update_text(self, "Incoming call").await?; call.update_text(self, "Incoming call").await?;
self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified self.emit_msgs_changed(call.msg.chat_id, call_id); // ringing calls are not additionally notified
let has_video = match sdp_has_video(&call.place_call_info) {
Ok(has_video) => has_video,
Err(err) => {
warn!(self, "Failed to determine if SDP offer has video: {err:#}.");
false
}
};
let can_call_me = match who_can_call_me(self).await? { let can_call_me = match who_can_call_me(self).await? {
WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id) WhoCanCallMe::Contacts => ChatIdBlocked::lookup_by_contact(self, from_id)
.await? .await?
@@ -377,7 +379,7 @@ impl Context {
msg_id: call.msg.id, msg_id: call.msg.id,
chat_id: call.msg.chat_id, chat_id: call.msg.chat_id,
place_call_info: call.place_call_info.to_string(), place_call_info: call.place_call_info.to_string(),
has_video, has_video: call.has_video_initially(),
}); });
} }
let wait = call.remaining_ring_seconds(); let wait = call.remaining_ring_seconds();
@@ -507,19 +509,6 @@ impl Context {
} }
} }
/// Returns true if SDP offer has a video.
pub fn sdp_has_video(sdp: &str) -> Result<bool> {
let mut cursor = Cursor::new(sdp);
let session_description =
SessionDescription::unmarshal(&mut cursor).context("Failed to parse SDP")?;
for media_description in &session_description.media_descriptions {
if media_description.media_name.media == "video" {
return Ok(true);
}
}
Ok(false)
}
/// State of the call for display in the message bubble. /// State of the call for display in the message bubble.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
pub enum CallState { pub enum CallState {

View File

@@ -25,13 +25,6 @@ async fn assert_text(t: &TestContext, call_id: MsgId, text: &str) -> Result<()>
const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n"; const PLACE_INFO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP4 host.anywhere.com\r\ns=-\r\nc=IN IP4 host.anywhere.com\r\nt=0 0\r\nm=audio 62986 RTP/AVP 0 4 18\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=rtpmap:18 G729/8000\r\na=inactive\r\n";
const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n"; const ACCEPT_INFO: &str = "v=0\r\no=bob 2890844730 2890844731 IN IP4 host.example.com\r\ns=\r\nc=IN IP4 host.example.com\r\nt=0 0\r\nm=audio 54344 RTP/AVP 0 4\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:4 G723/8000\r\na=inactive\r\n";
/// Example from <https://datatracker.ietf.org/doc/rfc9143/>
/// with `s= ` replaced with `s=-`.
///
/// `s=` cannot be empty according to RFC 3264,
/// so it is more clear as `s=-`.
const PLACE_INFO_VIDEO: &str = "v=0\r\no=alice 2890844526 2890844526 IN IP6 2001:db8::3\r\ns=-\r\nc=IN IP6 2001:db8::3\r\nt=0 0\r\na=group:BUNDLE foo bar\r\n\r\nm=audio 10000 RTP/AVP 0 8 97\r\nb=AS:200\r\na=mid:foo\r\na=rtcp-mux\r\na=rtpmap:0 PCMU/8000\r\na=rtpmap:8 PCMA/8000\r\na=rtpmap:97 iLBC/8000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n\r\nm=video 10002 RTP/AVP 31 32\r\nb=AS:1000\r\na=mid:bar\r\na=rtcp-mux\r\na=rtpmap:31 H261/90000\r\na=rtpmap:32 MPV/90000\r\na=extmap:1 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
async fn setup_call() -> Result<CallSetup> { async fn setup_call() -> Result<CallSetup> {
let mut tcm = TestContextManager::new(); let mut tcm = TestContextManager::new();
let alice = tcm.alice().await; let alice = tcm.alice().await;
@@ -52,7 +45,7 @@ async fn setup_call() -> Result<CallSetup> {
bob2.create_chat(&alice).await; bob2.create_chat(&alice).await;
let test_msg_id = alice let test_msg_id = alice
.place_outgoing_call(alice_chat.id, PLACE_INFO.to_string()) .place_outgoing_call(alice_chat.id, PLACE_INFO.to_string(), true)
.await?; .await?;
let sent1 = alice.pop_sent_msg().await; let sent1 = alice.pop_sent_msg().await;
assert_eq!(sent1.sender_msg_id, test_msg_id); assert_eq!(sent1.sender_msg_id, test_msg_id);
@@ -68,6 +61,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(!info.is_incoming()); assert!(!info.is_incoming());
assert!(!info.is_accepted()); assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO); assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Outgoing call").await?; assert_text(t, m.id, "Outgoing call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting); assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
} }
@@ -89,6 +83,7 @@ async fn setup_call() -> Result<CallSetup> {
assert!(info.is_incoming()); assert!(info.is_incoming());
assert!(!info.is_accepted()); assert!(!info.is_accepted());
assert_eq!(info.place_call_info, PLACE_INFO); assert_eq!(info.place_call_info, PLACE_INFO);
assert_eq!(info.has_video_initially(), true);
assert_text(t, m.id, "Incoming call").await?; assert_text(t, m.id, "Incoming call").await?;
assert_eq!(call_state(t, m.id).await?, CallState::Alerting); assert_eq!(call_state(t, m.id).await?, CallState::Alerting);
} }
@@ -525,13 +520,6 @@ async fn test_update_call_text() -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_sdp_has_video() {
assert!(sdp_has_video("foobar").is_err());
assert_eq!(sdp_has_video(PLACE_INFO).unwrap(), false);
assert_eq!(sdp_has_video(PLACE_INFO_VIDEO).unwrap(), true);
}
/// Tests that calls are forwarded as text messages. /// Tests that calls are forwarded as text messages.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_forward_call() -> Result<()> { async fn test_forward_call() -> Result<()> {
@@ -542,7 +530,7 @@ async fn test_forward_call() -> Result<()> {
let alice_bob_chat = alice.create_chat(bob).await; let alice_bob_chat = alice.create_chat(bob).await;
let alice_msg_id = alice let alice_msg_id = alice
.place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string()) .place_outgoing_call(alice_bob_chat.id, PLACE_INFO.to_string(), true)
.await .await
.context("Failed to place a call")?; .context("Failed to place a call")?;
let alice_call = Message::load_from_db(alice, alice_msg_id).await?; let alice_call = Message::load_from_db(alice, alice_msg_id).await?;

View File

@@ -91,6 +91,7 @@ pub enum HeaderDef {
ChatDispositionNotificationTo, ChatDispositionNotificationTo,
ChatWebrtcRoom, ChatWebrtcRoom,
ChatWebrtcAccepted, ChatWebrtcAccepted,
ChatWebrtcHasVideoInitially,
/// This message deletes the messages listed in the value by rfc724_mid. /// This message deletes the messages listed in the value by rfc724_mid.
ChatDelete, ChatDelete,

View File

@@ -1840,6 +1840,12 @@ impl MimeFactory {
mail_builder::headers::raw::Raw::new(b_encode(answer)).into(), mail_builder::headers::raw::Raw::new(b_encode(answer)).into(),
)); ));
} }
if let Some(has_video) = msg.param.get(Param::WebrtcHasVideoInitially) {
headers.push((
"Chat-Webrtc-Has-Video-Initially",
mail_builder::headers::raw::Raw::new(b_encode(has_video)).into(),
))
}
if msg.viewtype == Viewtype::Voice if msg.viewtype == Viewtype::Voice
|| msg.viewtype == Viewtype::Audio || msg.viewtype == Viewtype::Audio

View File

@@ -801,6 +801,9 @@ impl MimeMessage {
let accepted = self let accepted = self
.get_header(HeaderDef::ChatWebrtcAccepted) .get_header(HeaderDef::ChatWebrtcAccepted)
.map(|s| s.to_string()); .map(|s| s.to_string());
let has_video = self
.get_header(HeaderDef::ChatWebrtcHasVideoInitially)
.map(|s| s.to_string());
if let Some(part) = self.parts.first_mut() { if let Some(part) = self.parts.first_mut() {
if let Some(room) = room { if let Some(room) = room {
if content == "call" { if content == "call" {
@@ -810,6 +813,9 @@ impl MimeMessage {
} else if let Some(accepted) = accepted { } else if let Some(accepted) = accepted {
part.param.set(Param::WebrtcAccepted, accepted); part.param.set(Param::WebrtcAccepted, accepted);
} }
if let Some(has_video) = has_video {
part.param.set(Param::WebrtcHasVideoInitially, has_video);
}
} }
} }

View File

@@ -148,6 +148,9 @@ pub enum Param {
/// For Messages /// For Messages
WebrtcAccepted = b'7', WebrtcAccepted = b'7',
/// For Messages
WebrtcHasVideoInitially = b'z',
/// For Messages: space-separated list of messaged IDs of forwarded copies. /// For Messages: space-separated list of messaged IDs of forwarded copies.
/// ///
/// This is used when a [crate::message::Message] is in the /// This is used when a [crate::message::Message] is in the