1
0
Fork 0
mirror of https://github.com/Blah-IM/blahrs.git synced 2025-05-21 17:41:09 +00:00

feat: impl basic peer chat

This commit is contained in:
oxalica 2024-09-10 12:15:22 -04:00
parent 9e96927693
commit 1e944ead31
7 changed files with 361 additions and 64 deletions
blah-types/src
blahctl/src
blahd
docs

View file

@ -28,7 +28,9 @@ impl fmt::Display for Id {
}
impl Id {
pub const INVALID: Self = Id(i64::MAX);
pub const MIN: Self = Id(i64::MIN);
pub const MAX: Self = Id(i64::MAX);
pub const INVALID: Self = Self::MAX;
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -312,8 +314,8 @@ pub type ChatItem = WithSig<ChatPayload>;
pub struct RoomMetadata {
/// Room id.
pub rid: Id,
/// Plain text room title.
pub title: String,
/// Plain text room title. None for peer chat.
pub title: Option<String>,
/// Room attributes.
pub attrs: RoomAttrs,
@ -333,11 +335,23 @@ pub struct RoomMetadata {
/// Only available with authentication.
#[serde(skip_serializing_if = "Option::is_none")]
pub member_permission: Option<MemberPermission>,
/// The peer user, if this is a peer chat room.
#[serde(skip_serializing_if = "Option::is_none")]
pub peer_user: Option<UserKey>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "typ", rename = "create_room")]
pub struct CreateRoomPayload {
#[serde(tag = "typ")]
pub enum CreateRoomPayload {
#[serde(rename = "create_room")]
Group(CreateGroup),
#[serde(rename = "create_peer_chat")]
PeerChat(CreatePeerChat),
}
/// Multi-user room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreateGroup {
pub attrs: RoomAttrs,
/// The initial member list. Besides invariants of `RoomMemberList`, this also must include the
/// room creator themselves, with the highest permission (-1).
@ -345,6 +359,12 @@ pub struct CreateRoomPayload {
pub title: String,
}
/// Peer-to-peer chat room with exactly two symmetric users.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CreatePeerChat {
pub peer: UserKey,
}
/// A collection of room members, with these invariants:
/// 1. Sorted by userkeys.
/// 2. No duplicated users.
@ -408,10 +428,13 @@ pub enum RoomAdminOp {
}
bitflags::bitflags! {
/// TODO: Is this a really all about permission, or is a generic `UserFlags`?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ServerPermission: u64 {
const CREATE_ROOM = 1 << 0;
const ACCEPT_PEER_CHAT = 1 << 16;
const ALL = !0;
}
@ -421,6 +444,7 @@ bitflags::bitflags! {
const ADD_MEMBER = 1 << 1;
const MAX_SELF_ADD = Self::POST_CHAT.bits();
const MAX_PEER_CHAT = Self::POST_CHAT.bits();
const ALL = !0;
}
@ -430,6 +454,11 @@ bitflags::bitflags! {
const PUBLIC_READABLE = 1 << 0;
const PUBLIC_JOINABLE = 1 << 1;
const GROUP_ATTRS = (1 << 16) - 1;
// NB. Used by schema.
const PEER_CHAT = 1 << 16;
const _ = !0;
}
}

View file

@ -4,8 +4,8 @@ use std::{fs, io};
use anyhow::{Context, Result};
use blah_types::{
bitflags, get_timestamp, ChatPayload, CreateRoomPayload, Id, MemberPermission, RichText,
RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig,
bitflags, get_timestamp, ChatPayload, CreateGroup, CreateRoomPayload, Id, MemberPermission,
RichText, RoomAttrs, RoomMember, RoomMemberList, ServerPermission, UserKey, WithSig,
};
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, EncodePublicKey};
@ -217,7 +217,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
attrs,
} => {
let key = load_signing_key(&private_key_file)?;
let payload = CreateRoomPayload {
let payload = CreateRoomPayload::Group(CreateGroup {
attrs: attrs.unwrap_or_default(),
title,
// The CLI does not support passing multiple members because `User` itself is a
@ -226,7 +226,7 @@ async fn main_api(api_url: Url, command: ApiCommand) -> Result<()> {
permission: MemberPermission::ALL,
user: UserKey(key.verifying_key().to_bytes()),
}]),
};
});
let payload = WithSig::sign(&key, get_timestamp(), &mut OsRng, payload)?;
let ret = client

View file

@ -7,12 +7,26 @@ CREATE TABLE IF NOT EXISTS `user` (
`permission` INTEGER NOT NULL DEFAULT 0
) STRICT;
-- The highest bit of `rid` will be set for peer chat room.
-- So simply comparing it against 0 can filter them out.
CREATE TABLE IF NOT EXISTS `room` (
`rid` INTEGER NOT NULL PRIMARY KEY,
`title` TEXT NOT NULL,
-- RoomAttrs::PEER_CHAT
`attrs` INTEGER NOT NULL
CHECK ((`attrs` & 0x10000 == 0x10000) == `rid` < 0),
`title` TEXT
CHECK ((`title` ISNULL) == `rid` < 0),
`peer1` INTEGER REFERENCES `user` ON DELETE RESTRICT
CHECK ((`peer1` NOTNULL) == `rid` < 0),
`peer2` INTEGER REFERENCES `user` ON DELETE RESTRICT
CHECK ((`peer2` NOTNULL AND `peer1` <= `peer2`) IS `rid` < 0)
) STRICT;
CREATE UNIQUE INDEX IF NOT EXISTS `ix_peer_chat` ON `room`
(`peer1`, `peer2`)
WHERE `rid` < 0;
CREATE TABLE IF NOT EXISTS `room_member` (
`rid` INTEGER NOT NULL REFERENCES `room` ON DELETE CASCADE,
`uid` INTEGER NOT NULL REFERENCES `user` ON DELETE RESTRICT,

View file

@ -7,6 +7,7 @@ use blah_types::Id;
pub trait IdExt {
fn gen() -> Self;
fn gen_peer_chat_rid() -> Self;
}
impl IdExt for Id {
@ -14,8 +15,15 @@ impl IdExt for Id {
let timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("after UNIX epoch");
let timestamp_ms = timestamp.as_millis() as i64;
assert!(timestamp_ms > 0);
Id(timestamp_ms << 16)
let timestamp_ms = timestamp.as_millis();
assert!(
0 < timestamp_ms && timestamp_ms < (1 << 48),
"invalid timestamp",
);
Id((timestamp_ms as i64) << 16)
}
fn gen_peer_chat_rid() -> Self {
Id(Self::gen().0 | i64::MIN)
}
}

View file

@ -11,8 +11,9 @@ use axum::routing::{get, post};
use axum::{Json, Router};
use axum_extra::extract::WithRejection as R;
use blah_types::{
ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission, RoomAdminOp, RoomAdminPayload,
RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey, WithItemId, WithSig,
ChatItem, ChatPayload, CreateGroup, CreatePeerChat, CreateRoomPayload, Id, MemberPermission,
RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMetadata, ServerPermission, Signee, UserKey,
WithItemId, WithSig,
};
use config::ServerConfig;
use ed25519_dalek::SIGNATURE_LENGTH;
@ -177,7 +178,7 @@ async fn room_list(
until_token: None,
};
let page_len = pagination.effective_page_len(&st);
let start_rid = pagination.skip_token.unwrap_or(Id(0));
let start_rid = pagination.skip_token.unwrap_or(Id::MIN);
let query = |sql: &str, params: &[(&str, &dyn ToSql)]| -> Result<RoomList, ApiError> {
let rooms = st
@ -187,8 +188,6 @@ async fn room_list(
.query_map(params, |row| {
// TODO: Extract this into a function.
let rid = row.get("rid")?;
let title = row.get("title")?;
let attrs = row.get("attrs")?;
let last_item = row
.get::<_, Option<Id>>("cid")?
.map(|cid| {
@ -209,18 +208,16 @@ async fn room_list(
})
})
.transpose()?;
let last_seen_cid =
Some(row.get::<_, Id>("last_seen_cid")?).filter(|cid| cid.0 != 0);
let unseen_cnt = row.get("unseen_cnt").ok();
let member_permission = row.get("member_perm").ok();
Ok(RoomMetadata {
rid,
title,
attrs,
title: row.get("title")?,
attrs: row.get("attrs")?,
last_item,
last_seen_cid,
unseen_cnt,
member_permission,
last_seen_cid: Some(row.get::<_, Id>("last_seen_cid")?)
.filter(|cid| cid.0 != 0),
unseen_cnt: row.get("unseen_cnt").ok(),
member_permission: row.get("member_perm").ok(),
peer_user: row.get("peer_userkey").ok(),
})
})?
.collect::<Result<Vec<_>, _>>()?;
@ -255,12 +252,15 @@ async fn room_list(
r"
SELECT
`rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`,
`peer_user`.`userkey` AS `peer_userkey`
FROM `user`
JOIN `room_member` USING (`uid`)
JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`)
LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND
`rid` > :start_rid
GROUP BY `rid` HAVING `cid` IS MAX(`cid`)
@ -281,6 +281,7 @@ async fn room_list(
SELECT
`rid`, `title`, `attrs`, `last_seen_cid`, `room_member`.`permission` AS `member_perm`,
`cid`, `last_author`.`userkey`, `timestamp`, `nonce`, `sig`, `rich_text`,
`peer_user`.`userkey` AS `peer_userkey`,
(SELECT COUNT(*)
FROM `room_item` AS `unseen_item`
WHERE `unseen_item`.`rid` = `room`.`rid` AND
@ -290,6 +291,8 @@ async fn room_list(
JOIN `room` USING (`rid`)
LEFT JOIN `room_item` USING (`rid`)
LEFT JOIN `user` AS `last_author` ON (`last_author`.`uid` = `room_item`.`uid`)
LEFT JOIN `user` AS `peer_user` ON
(`peer_user`.`uid` = `room`.`peer1` + `room`.`peer2` - `user`.`uid`)
WHERE `user`.`userkey` = :userkey AND
`rid` > :start_rid AND
`cid` > `last_seen_cid`
@ -312,10 +315,29 @@ async fn room_create(
st: ArcState,
SignedJson(params): SignedJson<CreateRoomPayload>,
) -> Result<Json<Id>, ApiError> {
let members = &params.signee.payload.members.0;
match params.signee.payload {
CreateRoomPayload::Group(op) => room_create_group(&st, params.signee.user, op).await,
CreateRoomPayload::PeerChat(op) => room_create_peer_chat(&st, params.signee.user, op).await,
}
}
async fn room_create_group(
st: &AppState,
user: UserKey,
op: CreateGroup,
) -> Result<Json<Id>, ApiError> {
if !RoomAttrs::GROUP_ATTRS.contains(op.attrs) {
return Err(error_response!(
StatusCode::BAD_REQUEST,
"deserialization",
"invalid room attributes",
));
}
let members = &op.members.0;
if !members
.iter()
.any(|m| m.user == params.signee.user && m.permission == MemberPermission::ALL)
.any(|m| m.user == user && m.permission == MemberPermission::ALL)
{
return Err(error_response!(
StatusCode::BAD_REQUEST,
@ -332,7 +354,7 @@ async fn room_create(
FROM `user`
WHERE `userkey` = ?
",
params![params.signee.user],
params![user],
|row| {
let perm = row.get::<_, ServerPermission>("permission")?;
Ok(perm.contains(ServerPermission::CREATE_ROOM))
@ -356,8 +378,8 @@ async fn room_create(
",
named_params! {
":rid": rid,
":title": params.signee.payload.title,
":attrs": params.signee.payload.attrs,
":title": op.title,
":attrs": op.attrs,
},
)?;
let mut insert_user = txn.prepare(
@ -390,6 +412,118 @@ async fn room_create(
Ok(Json(rid))
}
async fn room_create_peer_chat(
st: &AppState,
src_user: UserKey,
op: CreatePeerChat,
) -> Result<Json<Id>, ApiError> {
let tgt_user = op.peer;
if tgt_user == src_user {
return Err(error_response!(
StatusCode::NOT_IMPLEMENTED,
"not_implemented",
"self-chat is not implemented yet",
));
}
// TODO: Access control and throttling.
let mut conn = st.db.get();
let txn = conn.transaction()?;
let tgt_uid = txn
.query_row(
r"
SELECT `uid`
FROM `user`
WHERE `userkey` = :userkey AND
`permission` & :perm = :perm
",
named_params! {
":userkey": tgt_user,
":perm": ServerPermission::ACCEPT_PEER_CHAT,
},
|row| row.get::<_, i64>(0),
)
.optional()?
.ok_or_else(|| {
error_response!(
StatusCode::NOT_FOUND,
"not_found",
"peer user does not exist or disallows peer chat",
)
})?;
let src_uid = txn
.query_row(
r"
SELECT `uid` FROM `user`
WHERE `userkey` = ?
",
params![src_user],
|row| row.get::<_, i64>(0),
)
.optional()?;
let src_uid = match src_uid {
Some(uid) => uid,
None => {
txn.execute(
r"
INSERT INTO `user` (`userkey`)
VALUES (?)
",
params![src_user],
)?;
txn.last_insert_rowid()
}
};
let (peer1, peer2) = if src_uid <= tgt_uid {
(src_uid, tgt_uid)
} else {
(tgt_uid, src_uid)
};
let rid = Id::gen_peer_chat_rid();
let updated = txn.execute(
r"
INSERT INTO `room` (`rid`, `attrs`, `peer1`, `peer2`)
VALUES (:rid, :attrs, :peer1, :peer2)
ON CONFLICT (`peer1`, `peer2`) WHERE `rid` < 0 DO NOTHING
",
named_params! {
":rid": rid,
":attrs": RoomAttrs::PEER_CHAT,
":peer1": peer1,
":peer2": peer2,
},
)?;
if updated == 0 {
return Err(error_response!(
StatusCode::CONFLICT,
"exists",
"room already exists"
));
}
{
let mut stmt = txn.prepare(
r"
INSERT INTO `room_member` (`rid`, `uid`, `permission`)
VALUES (:rid, :uid, :perm)
",
)?;
// TODO: Limit permission of the src user?
for uid in [peer1, peer2] {
stmt.execute(named_params! {
":rid": rid,
":uid": uid,
":perm": MemberPermission::MAX_PEER_CHAT,
})?;
}
}
txn.commit()?;
Ok(Json(rid))
}
/// Pagination query parameters.
///
/// Field names are inspired by Microsoft's design, which is an extension to OData spec.
@ -444,7 +578,7 @@ async fn room_get_metadata(
let conn = st.db.get();
let (title, attrs) = get_room_if_readable(&conn, rid, auth.into_optional()?.as_ref(), |row| {
Ok((
row.get::<_, String>("title")?,
row.get::<_, Option<String>>("title")?,
row.get::<_, RoomAttrs>("attrs")?,
))
})?;
@ -459,6 +593,7 @@ async fn room_get_metadata(
last_seen_cid: None,
unseen_cnt: None,
member_permission: None,
peer_user: None,
}))
}
@ -624,8 +759,8 @@ fn query_room_items(
.query_and_then(
named_params! {
":rid": rid,
":after_cid": pagination.until_token.unwrap_or(Id(-1)),
":before_cid": pagination.skip_token.unwrap_or(Id(i64::MAX)),
":after_cid": pagination.until_token.unwrap_or(Id::MIN),
":before_cid": pagination.skip_token.unwrap_or(Id::MAX),
":limit": page_len,
},
|row| {

View file

@ -7,9 +7,9 @@ use std::sync::{Arc, LazyLock};
use anyhow::Result;
use blah_types::{
get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateRoomPayload, Id, MemberPermission,
RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs, RoomMember, RoomMemberList, RoomMetadata,
ServerPermission, UserKey, WithItemId, WithSig,
get_timestamp, AuthPayload, ChatItem, ChatPayload, CreateGroup, CreatePeerChat,
CreateRoomPayload, Id, MemberPermission, RichText, RoomAdminOp, RoomAdminPayload, RoomAttrs,
RoomMember, RoomMemberList, RoomMetadata, ServerPermission, UserKey, WithItemId, WithSig,
};
use blahd::{ApiError, AppState, Database, RoomItems, RoomList};
use ed25519_dalek::SigningKey;
@ -29,7 +29,7 @@ const LOCALHOST: &str = "127.0.0.1";
static ALICE_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'A'; 32]));
static ALICE: LazyLock<UserKey> = LazyLock::new(|| UserKey(ALICE_PRIV.verifying_key().to_bytes()));
static BOB_PRIV: LazyLock<SigningKey> = LazyLock::new(|| SigningKey::from_bytes(&[b'B'; 32]));
// static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
static BOB: LazyLock<UserKey> = LazyLock::new(|| UserKey(BOB_PRIV.verifying_key().to_bytes()));
#[fixture]
fn rng() -> impl RngCore {
@ -118,14 +118,14 @@ impl Server {
let req = sign(
key,
&mut *self.rng.borrow_mut(),
CreateRoomPayload {
CreateRoomPayload::Group(CreateGroup {
attrs,
members: RoomMemberList(vec![RoomMember {
permission: MemberPermission::ALL,
user: UserKey(key.verifying_key().to_bytes()),
}]),
title: title.to_string(),
},
}),
);
async move {
Ok(self
@ -254,9 +254,10 @@ fn auth(key: &SigningKey, rng: &mut impl RngCore) -> String {
#[case::private(false)]
#[tokio::test]
async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] public: bool) {
let title = "test room";
let mut room_meta = RoomMetadata {
rid: Id(0),
title: "test room".into(),
title: Some(title.into()),
attrs: if public {
RoomAttrs::PUBLIC_READABLE | RoomAttrs::PUBLIC_JOINABLE
} else {
@ -266,18 +267,19 @@ async fn room_create_get(server: Server, ref mut rng: impl RngCore, #[case] publ
last_seen_cid: None,
unseen_cnt: None,
member_permission: None,
peer_user: None,
};
// Alice has permission.
let rid = server
.create_room(&ALICE_PRIV, room_meta.attrs, &room_meta.title)
.create_room(&ALICE_PRIV, room_meta.attrs, title)
.await
.unwrap();
room_meta.rid = rid;
// Bob has no permission.
server
.create_room(&BOB_PRIV, room_meta.attrs, &room_meta.title)
.create_room(&BOB_PRIV, room_meta.attrs, title)
.await
.expect_api_err(StatusCode::FORBIDDEN, "permission_denied");
@ -579,12 +581,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
RoomList {
rooms: vec![RoomMetadata {
rid,
title: title.into(),
title: Some(title.into()),
attrs,
last_item: Some(alice_chat2.clone()),
last_seen_cid: None,
unseen_cnt: Some(2),
member_permission: Some(member_perm),
peer_user: None,
}],
skip_token: None,
}
@ -612,12 +615,13 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
RoomList {
rooms: vec![RoomMetadata {
rid,
title: title.into(),
title: Some(title.into()),
attrs,
last_item: Some(alice_chat2.clone()),
last_seen_cid: Some(alice_chat1.cid),
unseen_cnt: Some(1),
member_permission: Some(member_perm),
peer_user: None,
}],
skip_token: None,
}
@ -639,3 +643,76 @@ async fn last_seen_item(server: Server, ref mut rng: impl RngCore) {
.unwrap();
assert_eq!(rooms, RoomList::default());
}
#[rstest]
#[tokio::test]
async fn peer_chat(server: Server, ref mut rng: impl RngCore) {
let mut create_chat = |src: &SigningKey, tgt: &UserKey| {
let req = sign(
src,
rng,
CreateRoomPayload::PeerChat(CreatePeerChat { peer: tgt.clone() }),
);
server
.request::<_, Id>(Method::POST, "/room/create", None, Some(req))
.map_ok(|resp| resp.unwrap())
};
// Bob disallows peer chat.
create_chat(&ALICE_PRIV, &BOB)
.await
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Alice accepts bob.
let rid = create_chat(&BOB_PRIV, &ALICE).await.unwrap();
// Room already exists.
create_chat(&BOB_PRIV, &ALICE)
.await
.expect_api_err(StatusCode::CONFLICT, "exists");
// Peer chat room is not public.
let rooms = server
.get::<RoomList>("/room?filter=public", None)
.await
.unwrap();
assert_eq!(rooms, RoomList::default());
server
.get::<RoomMetadata>(&format!("/room/{rid}"), None)
.await
.expect_api_err(StatusCode::NOT_FOUND, "not_found");
// Both alice and bob are in the room.
for (key, peer) in [(&*ALICE_PRIV, &*BOB), (&*BOB_PRIV, &*ALICE)] {
let mut expect_meta = RoomMetadata {
rid,
title: None,
attrs: RoomAttrs::PEER_CHAT,
last_item: None,
last_seen_cid: None,
unseen_cnt: None,
member_permission: None,
peer_user: None,
};
let meta = server
.get::<RoomMetadata>(&format!("/room/{rid}"), Some(&auth(key, rng)))
.await
.unwrap();
assert_eq!(meta, expect_meta);
expect_meta.member_permission = Some(MemberPermission::MAX_PEER_CHAT);
expect_meta.peer_user = Some(peer.clone());
let rooms = server
.get::<RoomList>("/room?filter=joined", Some(&auth(key, rng)))
.await
.unwrap();
assert_eq!(
rooms,
RoomList {
rooms: vec![expect_meta],
skip_token: None
}
);
}
}

View file

@ -108,6 +108,13 @@ paths:
/room/create:
post:
summary: Create room
description:
When `typ="create_room"`, create a multi-user room.
When `typ="create_peer_chat"`, create a peer-to-peer room between two
users. There can be at most one peer room for each given user pair.
requestBody:
content:
application/json:
@ -130,6 +137,20 @@ paths:
schema:
$ref: '#/components/schemas/ApiError'
404:
description: The peer user does not exist or disallows peer chat.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
409:
description: There is already a peer chat room between the user pair.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/room/{rid}:
get:
summary: Get room metadata
@ -391,6 +412,10 @@ components:
member_permission:
type: integer
format: int64
peer_user:
type: string
description: |
For peer chat room, this gives the identity of the peer user.
RoomMetadata:
type: object
@ -577,23 +602,32 @@ components:
type: integer
format: uint32
payload:
type: object
properties:
typ:
type: string
const: 'create_room'
title:
type: string
members:
type: array
items:
type: object
properties:
user:
type: string
permission:
type: integer
format: int64
oneOf:
- type: object
properties:
typ:
type: string
const: 'create_room'
title:
type: string
members:
type: array
items:
type: object
properties:
user:
type: string
permission:
type: integer
format: int64
- type: object
properties:
typ:
type: string
const: 'create_peer_chat'
peer:
type: string
example:
sig: 99a77e836538268839ed3419c649eefb043cb51d448f641cc2a1c523811aab4aacd09f92e7c0688ffd659bfc6acb764fea79979a491e132bf6a56dd23adc1d09
signee: