diff --git a/backend/canisters/community/CHANGELOG.md b/backend/canisters/community/CHANGELOG.md index 7e5655f978..5c3463410a 100644 --- a/backend/canisters/community/CHANGELOG.md +++ b/backend/canisters/community/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [unreleased] +### Added + +- Add/remove bot to/from community ([#6991](https://github.com/open-chat-labs/open-chat/pull/6991)) + ### Changed - Remove chat members from being stored on the heap ([#6942](https://github.com/open-chat-labs/open-chat/pull/6942)) diff --git a/backend/canisters/community/api/src/updates/add_bot.rs b/backend/canisters/community/api/src/updates/add_bot.rs new file mode 100644 index 0000000000..77380e868a --- /dev/null +++ b/backend/canisters/community/api/src/updates/add_bot.rs @@ -0,0 +1,20 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use ts_export::ts_export; +use types::{SlashCommandPermissions, UserId}; + +#[ts_export(community, add_bot)] +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub bot_id: UserId, + pub granted_permissions: SlashCommandPermissions, +} + +#[ts_export(community, add_bot)] +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + CommunityFrozen, + NotAuthorized, + AlreadyAdded, +} diff --git a/backend/canisters/community/api/src/updates/mod.rs b/backend/canisters/community/api/src/updates/mod.rs index 7ba418ad52..1a15c16d50 100644 --- a/backend/canisters/community/api/src/updates/mod.rs +++ b/backend/canisters/community/api/src/updates/mod.rs @@ -1,4 +1,5 @@ pub mod accept_p2p_swap; +pub mod add_bot; pub mod add_members_to_channel; pub mod add_reaction; pub mod block_user; @@ -41,6 +42,7 @@ pub mod pin_message; pub mod register_poll_vote; pub mod register_proposal_vote; pub mod register_proposal_vote_v2; +pub mod remove_bot; pub mod remove_member; pub mod remove_member_from_channel; pub mod remove_reaction; diff --git a/backend/canisters/community/api/src/updates/remove_bot.rs b/backend/canisters/community/api/src/updates/remove_bot.rs new file mode 100644 index 0000000000..d4200825c1 --- /dev/null +++ b/backend/canisters/community/api/src/updates/remove_bot.rs @@ -0,0 +1,17 @@ +use candid::CandidType; +use serde::{Deserialize, Serialize}; +use ts_export::ts_export; +use types::UserId; + +#[ts_export(community, remove_bot)] +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub struct Args { + pub bot_id: UserId, +} + +#[ts_export(community, remove_bot)] +#[derive(CandidType, Serialize, Deserialize, Debug)] +pub enum Response { + Success, + NotAuthorized, +} diff --git a/backend/canisters/community/impl/src/lib.rs b/backend/canisters/community/impl/src/lib.rs index f12982cac4..56ff187857 100644 --- a/backend/canisters/community/impl/src/lib.rs +++ b/backend/canisters/community/impl/src/lib.rs @@ -7,19 +7,21 @@ use activity_notification_state::ActivityNotificationState; use candid::Principal; use canister_state_macros::canister_state; use canister_timer_jobs::TimerJobs; -use chat_events::ChatMetricsInternal; +use chat_events::{ChatEventInternal, ChatMetricsInternal}; +use community_canister::add_members_to_channel::UserFailedError; use community_canister::EventsResponse; use constants::{MINUTE_IN_MS, SNS_LEDGER_CANISTER_ID}; use event_store_producer::{EventStoreClient, EventStoreClientBuilder, EventStoreClientInfo}; use event_store_producer_cdk_runtime::CdkRuntime; use fire_and_forget_handler::FireAndForgetHandler; use gated_groups::GatePayment; -use group_chat_core::AccessRulesInternal; +use group_chat_core::{AccessRulesInternal, AddResult}; use group_community_common::{ Achievements, ExpiringMember, ExpiringMemberActions, ExpiringMembers, Members, PaymentReceipts, PaymentRecipient, PendingPayment, PendingPaymentReason, PendingPaymentsQueue, UserCache, }; use instruction_counts_log::{InstructionCountEntry, InstructionCountFunctionId, InstructionCountsLog}; +use model::events::CommunityEventInternal; use model::user_event_batch::UserEventBatch; use model::{events::CommunityEvents, invited_users::InvitedUsers, members::CommunityMemberInternal}; use msgpack::serialize_then_unwrap; @@ -35,10 +37,10 @@ use std::ops::Deref; use std::time::Duration; use timer_job_queues::GroupedTimerJobQueue; use types::{ - AccessGate, AccessGateConfigInternal, Achievement, BuildVersion, CanisterId, ChannelId, ChatMetrics, + AccessGate, AccessGateConfigInternal, Achievement, BotAdded, BotRemoved, BuildVersion, CanisterId, ChannelId, ChatMetrics, CommunityCanisterCommunitySummary, CommunityMembership, CommunityPermissions, Cryptocurrency, Cycles, Document, Empty, - FrozenGroupInfo, Milliseconds, Notification, Rules, SlashCommandPermissions, TimestampMillis, Timestamped, UserId, - UserType, + EventIndex, FrozenGroupInfo, MembersAdded, MessageIndex, Milliseconds, Notification, Rules, SlashCommandPermissions, + TimestampMillis, Timestamped, UserId, UserType, }; use types::{CommunityId, SNS_FEE_SHARE_PERCENT}; use user_canister::CommunityCanisterEvent; @@ -723,6 +725,159 @@ impl Data { } } + pub fn add_members_to_channel( + &mut self, + channel_id: &ChannelId, + users_to_add: Vec<(UserId, UserType)>, + added_by: UserId, + now: TimestampMillis, + ) -> AddUsersToChannelResult { + let mut channel_name = None; + let mut channel_avatar_id = None; + let mut users_failed_with_error: Vec = Vec::new(); + let mut users_added: Vec = Vec::new(); + let mut users_already_in_channel: Vec = Vec::new(); + let mut users_limit_reached: Vec = Vec::new(); + + if let Some(channel) = self.channels.get_mut(channel_id) { + let mut min_visible_event_index = EventIndex::default(); + let mut min_visible_message_index = MessageIndex::default(); + + if !channel.chat.history_visible_to_new_joiners { + let events_reader = channel.chat.events.main_events_reader(); + min_visible_event_index = events_reader.next_event_index(); + min_visible_message_index = events_reader.next_message_index(); + } + + let gate_expiry = channel.chat.gate_config.value.as_ref().and_then(|gc| gc.expiry()); + + for (user_id, user_type) in users_to_add { + match channel.chat.members.add( + user_id, + now, + min_visible_event_index, + min_visible_message_index, + channel.chat.is_public.value, + user_type, + ) { + AddResult::Success(_) => { + self.members.mark_member_joined_channel(user_id, channel.id); + + if !matches!(user_type, UserType::BotV2) { + users_added.push(user_id); + } + + if !user_type.is_bot() { + if let Some(gate_expiry) = gate_expiry { + self.expiring_members.push(ExpiringMember { + expires: now + gate_expiry, + channel_id: Some(channel.id), + user_id, + }); + } + } + } + AddResult::AlreadyInGroup => users_already_in_channel.push(user_id), + AddResult::MemberLimitReached(_) => users_limit_reached.push(user_id), + AddResult::Blocked => users_failed_with_error.push(UserFailedError { + user_id, + error: "User blocked".to_string(), + }), + } + } + + if !users_added.is_empty() { + let event = MembersAdded { + user_ids: users_added.clone(), + added_by, + unblocked: Vec::new(), + }; + + channel + .chat + .events + .push_main_event(ChatEventInternal::ParticipantsAdded(Box::new(event)), 0, now); + } + + channel_name = Some(channel.chat.name.value.clone()); + channel_avatar_id = channel.chat.avatar.as_ref().map(|d| d.id); + } + + AddUsersToChannelResult { + channel_name, + channel_avatar_id, + users_failed_with_error, + users_added, + users_already_in_channel, + users_limit_reached, + } + } + + pub fn add_bot( + &mut self, + owner_id: UserId, + bot_user_id: UserId, + granted_permissions: SlashCommandPermissions, + now: TimestampMillis, + ) -> bool { + if !self.bot_permissions.contains_key(&bot_user_id) { + return false; + } + + // Insert the granted bot permissions + self.bot_permissions.insert(bot_user_id, granted_permissions); + + // Add the bot as a community member + self.members.add(bot_user_id, bot_user_id.into(), UserType::BotV2, None, now); + self.invited_users.remove(&bot_user_id, now); + + // Add the bot as a member of each channel + let channel_ids: Vec<_> = self.channels.iter().map(|c| c.id).collect(); + for channel_id in channel_ids { + self.add_members_to_channel(&channel_id, vec![(bot_user_id, UserType::BotV2)], owner_id, now); + } + + // Publish community event + self.events.push_event( + CommunityEventInternal::BotAdded(Box::new(BotAdded { + bot_id: bot_user_id, + added_by: owner_id, + })), + now, + ); + + // TODO: Notify UserIndex + + true + } + + pub fn remove_bot(&mut self, owner_id: UserId, bot_user_id: UserId, now: TimestampMillis) -> bool { + if self.bot_permissions.remove(&bot_user_id).is_none() { + return false; + } + + // Remove bot user from each channel + for channel in self.channels.iter_mut() { + channel.chat.remove_member(owner_id, bot_user_id, false, now); + } + + // Remove bot user from the community + self.remove_user_from_community(bot_user_id, now); + + // Publish community event + self.events.push_event( + CommunityEventInternal::BotRemoved(Box::new(BotRemoved { + bot_id: bot_user_id, + removed_by: owner_id, + })), + now, + ); + + // TODO: Notify UserIndex + + true + } + pub fn get_bot_permissions(&self, bot_user_id: &UserId) -> Option<&SlashCommandPermissions> { self.bot_permissions.get(bot_user_id) } @@ -801,3 +956,12 @@ pub struct CanisterIds { pub icp_ledger: CanisterId, pub internet_identity: CanisterId, } + +pub struct AddUsersToChannelResult { + pub channel_name: Option, + pub channel_avatar_id: Option, + pub users_failed_with_error: Vec, + pub users_added: Vec, + pub users_already_in_channel: Vec, + pub users_limit_reached: Vec, +} diff --git a/backend/canisters/community/impl/src/model/events.rs b/backend/canisters/community/impl/src/model/events.rs index 65c40a2d84..42cda69cb5 100644 --- a/backend/canisters/community/impl/src/model/events.rs +++ b/backend/canisters/community/impl/src/model/events.rs @@ -4,11 +4,11 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use tracing::info; use types::{ - AvatarChanged, BannerChanged, ChannelDeleted, ChannelId, ChatId, CommunityMemberLeftInternal, CommunityMembersRemoved, - CommunityPermissionsChanged, CommunityRoleChanged, CommunityUsersBlocked, CommunityVisibilityChanged, - DefaultChannelsChanged, EventIndex, EventWrapperInternal, GroupCreated, GroupDescriptionChanged, GroupFrozen, - GroupInviteCodeChanged, GroupNameChanged, GroupRulesChanged, GroupUnfrozen, MemberJoinedInternal, PrimaryLanguageChanged, - TimestampMillis, UserId, UsersInvited, UsersUnblocked, + AvatarChanged, BannerChanged, BotAdded, BotRemoved, ChannelDeleted, ChannelId, ChatId, CommunityMemberLeftInternal, + CommunityMembersRemoved, CommunityPermissionsChanged, CommunityRoleChanged, CommunityUsersBlocked, + CommunityVisibilityChanged, DefaultChannelsChanged, EventIndex, EventWrapperInternal, GroupCreated, + GroupDescriptionChanged, GroupFrozen, GroupInviteCodeChanged, GroupNameChanged, GroupRulesChanged, GroupUnfrozen, + MemberJoinedInternal, PrimaryLanguageChanged, TimestampMillis, UserId, UsersInvited, UsersUnblocked, }; mod stable_memory; @@ -119,6 +119,10 @@ pub enum CommunityEventInternal { PrimaryLanguageChanged(Box), #[serde(rename = "gi", alias = "GroupImported")] GroupImported(Box), + #[serde(rename = "ba")] + BotAdded(Box), + #[serde(rename = "br")] + BotRemoved(Box), } impl CommunityEvents { diff --git a/backend/canisters/community/impl/src/updates/add_bot.rs b/backend/canisters/community/impl/src/updates/add_bot.rs new file mode 100644 index 0000000000..8b3ff39229 --- /dev/null +++ b/backend/canisters/community/impl/src/updates/add_bot.rs @@ -0,0 +1,38 @@ +use crate::{activity_notifications::handle_activity_notification, mutate_state, run_regular_jobs, RuntimeState}; +use canister_api_macros::update; +use canister_tracing_macros::trace; +use community_canister::add_bot::{Response::*, *}; + +#[update(msgpack = true)] +#[trace] +fn add_bot(args: Args) -> Response { + run_regular_jobs(); + + mutate_state(|state| add_bot_impl(args, state)) +} + +fn add_bot_impl(args: Args, state: &mut RuntimeState) -> Response { + if state.data.is_frozen() { + return CommunityFrozen; + } + + let caller = state.env.caller(); + + let Some(member) = state.data.members.get(caller) else { + return NotAuthorized; + }; + + if member.suspended().value || !member.role().is_owner() { + return NotAuthorized; + } + + if !state + .data + .add_bot(member.user_id, args.bot_id, args.granted_permissions, state.env.now()) + { + return AlreadyAdded; + } + + handle_activity_notification(state); + Success +} diff --git a/backend/canisters/community/impl/src/updates/add_members_to_channel.rs b/backend/canisters/community/impl/src/updates/add_members_to_channel.rs index bc82880aa7..97e0ac598d 100644 --- a/backend/canisters/community/impl/src/updates/add_members_to_channel.rs +++ b/backend/canisters/community/impl/src/updates/add_members_to_channel.rs @@ -1,13 +1,11 @@ use crate::{ - activity_notifications::handle_activity_notification, jobs, mutate_state, read_state, run_regular_jobs, RuntimeState, + activity_notifications::handle_activity_notification, jobs, mutate_state, read_state, run_regular_jobs, + AddUsersToChannelResult, RuntimeState, }; use canister_api_macros::update; use canister_tracing_macros::trace; -use chat_events::ChatEventInternal; use community_canister::add_members_to_channel::{Response::*, *}; -use group_chat_core::AddResult; -use group_community_common::ExpiringMember; -use types::{AddedToChannelNotification, ChannelId, EventIndex, MembersAdded, MessageIndex, Notification, UserId, UserType}; +use types::{AddedToChannelNotification, ChannelId, Notification, UserId, UserType}; #[update(msgpack = true)] #[trace] @@ -26,8 +24,6 @@ fn add_members_to_channel(args: Args) -> Response { prepare_result.member_display_name.or(args.added_by_display_name), args.channel_id, prepare_result.users_to_add, - prepare_result.users_already_in_channel, - prepare_result.users_not_in_community, state, ) }) @@ -36,8 +32,6 @@ fn add_members_to_channel(args: Args) -> Response { struct PrepareResult { user_id: UserId, users_to_add: Vec<(UserId, UserType)>, - users_already_in_channel: Vec, - users_not_in_community: Vec, member_display_name: Option, } @@ -73,27 +67,17 @@ fn prepare(args: &Args, state: &RuntimeState) -> Result return Err(UserLapsed); } - let mut users_to_add = Vec::new(); - let mut users_already_in_channel = Vec::new(); - let mut users_not_in_community = Vec::new(); - - for user_id in args.user_ids.iter() { - if let Some(member) = state.data.members.get_by_user_id(user_id) { - if !channel.chat.members.contains(user_id) { - users_to_add.push((*user_id, member.user_type)); - } else { - users_already_in_channel.push(*user_id); - } - } else { - users_not_in_community.push(*user_id); - } - } + // Only add users who are already community members + let users_to_add = args + .user_ids + .iter() + .filter(|user_id| state.data.members.contains(user_id)) + .map(|user_id| (*user_id, state.data.members.bots().get(user_id).copied().unwrap_or_default())) + .collect(); Ok(PrepareResult { user_id, users_to_add, - users_already_in_channel, - users_not_in_community, member_display_name: member.display_name().value.clone(), }) } else { @@ -114,107 +98,57 @@ fn commit( added_by_display_name: Option, channel_id: ChannelId, users_to_add: Vec<(UserId, UserType)>, - mut users_already_in_channel: Vec, - _users_not_in_community: Vec, state: &mut RuntimeState, ) -> Response { - let mut users_failed_with_error: Vec = Vec::new(); - - if let Some(channel) = state.data.channels.get_mut(&channel_id) { - let mut min_visible_event_index = EventIndex::default(); - let mut min_visible_message_index = MessageIndex::default(); - - if !channel.chat.history_visible_to_new_joiners { - let events_reader = channel.chat.events.main_events_reader(); - min_visible_event_index = events_reader.next_event_index(); - min_visible_message_index = events_reader.next_message_index(); - } - - let now = state.env.now(); - - let mut users_added: Vec = Vec::new(); - let mut users_limit_reached: Vec = Vec::new(); - - let gate_expiry = channel.chat.gate_config.value.as_ref().and_then(|gc| gc.expiry()); - - for (user_id, user_type) in users_to_add { - match channel.chat.members.add( - user_id, - now, - min_visible_event_index, - min_visible_message_index, - channel.chat.is_public.value, - user_type, - ) { - AddResult::Success(_) => { - users_added.push(user_id); - state.data.members.mark_member_joined_channel(user_id, channel_id); - - if let Some(gate_expiry) = gate_expiry { - state.data.expiring_members.push(ExpiringMember { - expires: now + gate_expiry, - channel_id: Some(channel_id), - user_id, - }); - } - } - AddResult::AlreadyInGroup => users_already_in_channel.push(user_id), - AddResult::MemberLimitReached(_) => users_limit_reached.push(user_id), - AddResult::Blocked => users_failed_with_error.push(UserFailedError { - user_id, - error: "User blocked".to_string(), - }), - } - } - - if users_added.is_empty() { - return Failed(FailedResult { - users_already_in_channel, - users_limit_reached, - users_failed_with_error, - }); - } + let now = state.env.now(); + + let AddUsersToChannelResult { + channel_name, + channel_avatar_id, + users_added, + users_already_in_channel, + users_limit_reached, + users_failed_with_error, + } = state.data.add_members_to_channel(&channel_id, users_to_add, added_by, now); + + let Some(channel_name) = channel_name else { + return ChannelNotFound; + }; - let event = MembersAdded { - user_ids: users_added.clone(), - added_by, - unblocked: Vec::new(), - }; - - channel - .chat - .events - .push_main_event(ChatEventInternal::ParticipantsAdded(Box::new(event)), 0, now); - - let notification = Notification::AddedToChannel(AddedToChannelNotification { - community_id: state.env.canister_id().into(), - community_name: state.data.name.value.clone(), - channel_id, - channel_name: channel.chat.name.value.clone(), - added_by, - added_by_name, - added_by_display_name, - community_avatar_id: state.data.avatar.as_ref().map(|d| d.id), - channel_avatar_id: channel.chat.avatar.as_ref().map(|d| d.id), + if users_added.is_empty() { + return Failed(FailedResult { + users_already_in_channel, + users_limit_reached, + users_failed_with_error, }); + } - state.push_notification(users_added.clone(), notification); - - jobs::expire_members::start_job_if_required(state); - - handle_activity_notification(state); - - if !users_already_in_channel.is_empty() || !users_failed_with_error.is_empty() { - PartialSuccess(PartialSuccessResult { - users_added, - users_limit_reached, - users_already_in_channel, - users_failed_with_error, - }) - } else { - Success - } + let notification = Notification::AddedToChannel(AddedToChannelNotification { + community_id: state.env.canister_id().into(), + community_name: state.data.name.value.clone(), + channel_id, + channel_name, + added_by, + added_by_name, + added_by_display_name, + community_avatar_id: state.data.avatar.as_ref().map(|d| d.id), + channel_avatar_id, + }); + + state.push_notification(users_added.clone(), notification); + + jobs::expire_members::start_job_if_required(state); + + handle_activity_notification(state); + + if !users_already_in_channel.is_empty() || !users_failed_with_error.is_empty() { + PartialSuccess(PartialSuccessResult { + users_added, + users_limit_reached, + users_already_in_channel, + users_failed_with_error, + }) } else { - ChannelNotFound + Success } } diff --git a/backend/canisters/community/impl/src/updates/mod.rs b/backend/canisters/community/impl/src/updates/mod.rs index cfa31cb293..baf5a178fa 100644 --- a/backend/canisters/community/impl/src/updates/mod.rs +++ b/backend/canisters/community/impl/src/updates/mod.rs @@ -1,4 +1,5 @@ pub mod accept_p2p_swap; +pub mod add_bot; pub mod add_members_to_channel; pub mod add_reaction; pub mod c2c_delete_community; @@ -38,6 +39,7 @@ pub mod pin_message; pub mod register_poll_vote; pub mod register_proposal_vote; pub mod register_proposal_vote_v2; +pub mod remove_bot; pub mod remove_member; pub mod remove_member_from_channel; pub mod remove_reaction; diff --git a/backend/canisters/community/impl/src/updates/remove_bot.rs b/backend/canisters/community/impl/src/updates/remove_bot.rs new file mode 100644 index 0000000000..4a8d19f759 --- /dev/null +++ b/backend/canisters/community/impl/src/updates/remove_bot.rs @@ -0,0 +1,29 @@ +use crate::{activity_notifications::handle_activity_notification, mutate_state, run_regular_jobs, RuntimeState}; +use canister_api_macros::update; +use canister_tracing_macros::trace; +use community_canister::remove_bot::{Response::*, *}; + +#[update(msgpack = true)] +#[trace] +fn remove_bot(args: Args) -> Response { + run_regular_jobs(); + + mutate_state(|state| remove_bot_impl(args, state)) +} + +fn remove_bot_impl(args: Args, state: &mut RuntimeState) -> Response { + let caller = state.env.caller(); + + let Some(member) = state.data.members.get(caller) else { + return NotAuthorized; + }; + + if member.suspended().value || !member.role().is_owner() { + return NotAuthorized; + } + + state.data.remove_bot(member.user_id, args.bot_id, state.env.now()); + + handle_activity_notification(state); + Success +} diff --git a/backend/libraries/group_chat_core/src/lib.rs b/backend/libraries/group_chat_core/src/lib.rs index fdb26ddc43..c445daab4b 100644 --- a/backend/libraries/group_chat_core/src/lib.rs +++ b/backend/libraries/group_chat_core/src/lib.rs @@ -1373,6 +1373,8 @@ impl GroupChatCore { .role() .can_remove_members_with_role(target_member_role, &self.permissions) { + let is_bot_v2 = matches!(member.user_type(), UserType::BotV2); + // Remove the user from the group self.members.remove(target_user_id, now); @@ -1381,22 +1383,24 @@ impl GroupChatCore { return Success; } - // Push relevant event - let event = if block { - let event = UsersBlocked { - user_ids: vec![target_user_id], - blocked_by: user_id, - }; + if !is_bot_v2 { + // Push relevant event + let event = if block { + let event = UsersBlocked { + user_ids: vec![target_user_id], + blocked_by: user_id, + }; - ChatEventInternal::UsersBlocked(Box::new(event)) - } else { - let event = MembersRemoved { - user_ids: vec![target_user_id], - removed_by: user_id, + ChatEventInternal::UsersBlocked(Box::new(event)) + } else { + let event = MembersRemoved { + user_ids: vec![target_user_id], + removed_by: user_id, + }; + ChatEventInternal::ParticipantsRemoved(Box::new(event)) }; - ChatEventInternal::ParticipantsRemoved(Box::new(event)) - }; - self.events.push_main_event(event, 0, now); + self.events.push_main_event(event, 0, now); + } Success } else { diff --git a/backend/libraries/types/src/events.rs b/backend/libraries/types/src/events.rs index c5edb23a93..eadb80575f 100644 --- a/backend/libraries/types/src/events.rs +++ b/backend/libraries/types/src/events.rs @@ -428,3 +428,17 @@ impl EventContext { } } } + +#[ts_export] +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct BotAdded { + pub bot_id: UserId, + pub added_by: UserId, +} + +#[ts_export] +#[derive(CandidType, Serialize, Deserialize, Clone, Debug)] +pub struct BotRemoved { + pub bot_id: UserId, + pub removed_by: UserId, +}