diff --git a/Cargo.lock b/Cargo.lock index 1b755378..5bd5c3b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5688,6 +5688,29 @@ dependencies = [ "sp-std", ] +[[package]] +name = "pallet-moderation" +version = "0.1.8" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-permissions", + "pallet-posts", + "pallet-roles", + "pallet-space-follows", + "pallet-spaces", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-core", + "sp-io", + "sp-runtime", + "sp-std", + "subsocial-support", +] + [[package]] name = "pallet-multisig" version = "4.0.0-dev" @@ -11293,6 +11316,7 @@ dependencies = [ "pallet-domains", "pallet-energy", "pallet-free-proxy", + "pallet-moderation", "pallet-permissions", "pallet-posts", "pallet-profiles", diff --git a/pallets/moderation/Cargo.toml b/pallets/moderation/Cargo.toml new file mode 100644 index 00000000..839d7ca3 --- /dev/null +++ b/pallets/moderation/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = 'pallet-moderation' +version = "0.1.8" +authors = ["DappForce "] +edition = "2018" +license = "GPL-3.0-only" +homepage = "https://subsocial.network" +repository = "https://github.com/dappforce/subsocial-parachain" +description = 'Subsocial pallet for content moderation' +keywords = ["blockchain", "cryptocurrency", "social-network", "news-feed", "marketplace"] +categories = ["cryptography::cryptocurrencies"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] + +[dependencies] +codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } +scale-info = { version = "2.2.0", default-features = false, features = ["derive"] } + +subsocial-support = { path = "../support", default-features = false } +pallet-permissions = { default-features = false, path = '../permissions' } +pallet-posts = { default-features = false, path = '../posts' } +pallet-roles = { default-features = false, path = '../roles' } +pallet-space-follows = { default-features = false, path = '../space-follows' } +pallet-spaces = { default-features = false, path = '../spaces' } + +frame-benchmarking = { optional = true, git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +frame-support = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +frame-system = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +sp-runtime = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +sp-std = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } + +[dev-dependencies] +pallet-balances = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +pallet-timestamp = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +sp-io = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } +sp-core = { git = "https://github.com/paritytech/substrate", branch = "polkadot-v0.9.37", default-features = false } + +[features] +default = ["std"] +runtime-benchmarks = ["frame-benchmarking"] +std = [ + "codec/std", + "scale-info/std", + "pallet-balances/std", + "frame-support/std", + "frame-system/std", + "frame-benchmarking/std", + "sp-runtime/std", + "sp-std/std", + "subsocial-support/std", + "pallet-permissions/std", + "pallet-posts/std", + "pallet-roles/std", + "pallet-space-follows/std", + "pallet-spaces/std", + +] +try-runtime = ["frame-support/try-runtime"] diff --git a/pallets/moderation/src/functions.rs b/pallets/moderation/src/functions.rs new file mode 100644 index 00000000..5e707fc4 --- /dev/null +++ b/pallets/moderation/src/functions.rs @@ -0,0 +1,173 @@ +use crate::*; + +use frame_support::dispatch::DispatchError; +use pallet_posts::Pallet as Posts; +use pallet_spaces::Pallet as Spaces; +use pallet_spaces::types::Space; +use pallet_space_follows::Pallet as SpaceFollows; +use subsocial_support::{Content, ensure_content_is_some, new_who_and_when, PostId, SpaceId}; +use subsocial_support::traits::{IsAccountBlocked, IsContentBlocked, IsPostBlocked, IsSpaceBlocked}; +use crate::pallet::{Config, EntityId, EntityStatus, Error, Pallet, Report, ReportId, SpaceModerationSettings, StatusByEntityInSpace, SuggestedStatus}; + +impl Pallet { + pub fn require_report(report_id: ReportId) -> Result, DispatchError> { + Ok(Self::report_by_id(report_id).ok_or(Error::::ReportNotFound)?) + } + + /// Get entity space_id if it exists. + /// Content and Account has no scope, consider check with `if let Some` + fn get_entity_scope(entity: &EntityId) -> Result, DispatchError> { + match entity { + EntityId::Content(content) => { + ensure_content_is_some(content).map(|_| None) + }, + EntityId::Account(_) => Ok(None), + EntityId::Space(space_id) => { + // TODO: refactor + // let space = Spaces::::require_space(*space_id)?; + // let root_space_id = space.try_get_parent()?; + // + // Ok(Some(root_space_id)) + Ok(None) + }, + EntityId::Post(post_id) => { + let post = Posts::::require_post(*post_id)?; + let space_id = post.get_space()?.id; + + Ok(Some(space_id)) + }, + } + } + + #[allow(dead_code)] + // fixme: do we need this? + fn ensure_entity_exists(entity: &EntityId) -> DispatchResult { + match entity { + EntityId::Content(content) => ensure_content_is_some(content), + EntityId::Account(_) => Ok(()), + EntityId::Space(space_id) => Spaces::::ensure_space_exists(*space_id), + EntityId::Post(post_id) => Posts::::ensure_post_exists(*post_id), + }.map_err(|_| Error::::EntityNotFound.into()) + } + + pub(crate) fn block_entity_in_scope(entity: &EntityId, scope: SpaceId) -> DispatchResult { + // TODO: update counters, when entity is moved + // TODO: think, what and where we should change something if entity is moved + match entity { + EntityId::Content(_) => (), + EntityId::Account(account_id) + => SpaceFollows::::remove_space_follower(account_id.clone(), scope)?, + EntityId::Space(space_id) => /*TODO:refactor*//*Spaces::::try_move_space_to_root(*space_id)?*/{}, + EntityId::Post(post_id) => Posts::::delete_post_from_space(*post_id)?, + } + StatusByEntityInSpace::::insert(entity, scope, EntityStatus::Blocked); + Ok(()) + } + + pub(crate) fn ensure_account_status_manager(who: T::AccountId, space: &Space) -> DispatchResult { + Spaces::::ensure_account_has_space_permission( + who, + &space, + pallet_permissions::SpacePermission::UpdateEntityStatus, + Error::::NoPermissionToUpdateEntityStatus.into(), + ) + } + + pub(crate) fn ensure_entity_in_scope(entity: &EntityId, scope: SpaceId) -> DispatchResult { + if let Some(entity_scope) = Self::get_entity_scope(entity)? { + ensure!(entity_scope == scope, Error::::EntityNotInScope); + } + Ok(()) + } + + pub fn default_autoblock_threshold_as_settings() -> SpaceModerationSettings { + SpaceModerationSettings { + autoblock_threshold: Some(T::DefaultAutoblockThreshold::get()) + } + } +} + +impl Report { + pub fn new( + id: ReportId, + created_by: T::AccountId, + reported_entity: EntityId, + scope: SpaceId, + reason: Content + ) -> Self { + Self { + id, + created: new_who_and_when::(created_by), + reported_entity, + reported_within: scope, + reason + } + } +} + +impl SuggestedStatus { + pub fn new(who: T::AccountId, status: Option, report_id: Option) -> Self { + Self { + suggested: new_who_and_when::(who), + status, + report_id + } + } +} + +// TODO: maybe simplify using one common trait? +impl IsAccountBlocked for Pallet { + fn is_blocked_account(account: T::AccountId, scope: SpaceId) -> bool { + let entity = EntityId::Account(account); + + Self::status_by_entity_in_space(entity, scope) == Some(EntityStatus::Blocked) + } + + fn is_allowed_account(account: T::AccountId, scope: SpaceId) -> bool { + let entity = EntityId::Account(account); + + Self::status_by_entity_in_space(entity, scope) != Some(EntityStatus::Blocked) + } +} + +impl IsSpaceBlocked for Pallet { + fn is_blocked_space(space_id: SpaceId, scope: SpaceId) -> bool { + let entity = EntityId::Space(space_id); + + Self::status_by_entity_in_space(entity, scope) == Some(EntityStatus::Blocked) + } + + fn is_allowed_space(space_id: SpaceId, scope: SpaceId) -> bool { + let entity = EntityId::Space(space_id); + + Self::status_by_entity_in_space(entity, scope) != Some(EntityStatus::Blocked) + } +} + +impl IsPostBlocked for Pallet { + fn is_blocked_post(post_id: PostId, scope: SpaceId) -> bool { + let entity = EntityId::Post(post_id); + + Self::status_by_entity_in_space(entity, scope) == Some(EntityStatus::Blocked) + } + + fn is_allowed_post(post_id: PostId, scope: SpaceId) -> bool { + let entity = EntityId::Post(post_id); + + Self::status_by_entity_in_space(entity, scope) != Some(EntityStatus::Blocked) + } +} + +impl IsContentBlocked for Pallet { + fn is_blocked_content(content: Content, scope: SpaceId) -> bool { + let entity = EntityId::Content(content); + + Self::status_by_entity_in_space(entity, scope) == Some(EntityStatus::Blocked) + } + + fn is_allowed_content(content: Content, scope: SpaceId) -> bool { + let entity = EntityId::Content(content); + + Self::status_by_entity_in_space(entity, scope) != Some(EntityStatus::Blocked) + } +} \ No newline at end of file diff --git a/pallets/moderation/src/lib.rs b/pallets/moderation/src/lib.rs new file mode 100644 index 00000000..eb8ebf2e --- /dev/null +++ b/pallets/moderation/src/lib.rs @@ -0,0 +1,431 @@ +//! # Moderation Module +//! +//! The Moderation module allows any user (account) to report an account, space, post or even +//! IPFS CID, if they think it's a spam, abuse or inappropriate for a specific space. +//! +//! Moderators of a space can review reported entities and suggest a moderation status for them: +//! `Block` or `Allowed`. A space owner can make a final decision: either block or allow any entity +//! within the space they control. +//! +//! This pallet also has a setting to auto-block the content after a specific number of statuses +//! from moderators that suggest to block the entity. If the entity is added to allow list, +//! then the entity cannot be blocked. +//! +//! The next rules applied to the blocked entities: +//! +//! - A post cannot be added to a space if an IPFS CID of this post is blocked in this space. +//! - An account cannot create posts in a space if this account is blocked in this space. + +#![cfg_attr(not(feature = "std"), no_std)] + +use codec::{Encode, Decode}; +use scale_info::TypeInfo; +use sp_std::prelude::*; +use sp_runtime::RuntimeDebug; +use frame_support::{ + decl_module, decl_storage, decl_event, decl_error, ensure, + dispatch::DispatchResult, + traits::Get, +}; +use frame_system::{self as system, ensure_signed}; + +use subsocial_support::{Content, new_who_and_when, PostId, SpaceId, ensure_content_is_valid, ensure_content_is_some, WhoAndWhenOf}; +use pallet_spaces::Module as Spaces; + +pub use pallet::*; + + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +pub mod functions; + +#[frame_support::pallet] +pub mod pallet { + + use super::*; + use frame_support::pallet_prelude::*; + use frame_system::pallet_prelude::*; + use sp_std::convert::TryInto; + + pub type ReportId = u64; + + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + pub enum EntityId { + Content(Content), + Account(AccountId), + Space(SpaceId), + Post(PostId), + } + + /// Entity status is used in two cases: when moderators suggest a moderation status + /// for a reported entity; or when a space owner makes a final decision to either block + /// or allow this entity within the space. + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + pub enum EntityStatus { + Allowed, + Blocked, + } + + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + #[scale_info(skip_type_params(T))] + pub struct Report { + pub(crate) id: ReportId, + pub(crate) created: WhoAndWhenOf, + /// An id of reported entity: account, space, post or IPFS CID. + pub(crate) reported_entity: EntityId, + /// Within what space (scope) this entity has been reported. + pub(crate) reported_within: SpaceId, // TODO rename: reported_in_space + /// A reason should describe why this entity should be blocked in this space. + pub(crate) reason: Content, + } + + // TODO rename to SuggestedEntityStatus + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + #[scale_info(skip_type_params(T))] + pub struct SuggestedStatus { + /// An account id of a moderator who suggested this status. + pub(crate) suggested: WhoAndWhenOf, + /// `None` if a moderator wants to signal that they have reviewed the entity, + /// but they are not sure about what status should be applied to it. + pub(crate) status: Option, + /// `None` if a suggested status is not based on any reports. + pub(crate) report_id: Option, + } + + // TODO rename to ModerationSettings? + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + pub struct SpaceModerationSettings { + pub(crate) autoblock_threshold: Option + } + + // TODO rename to ModerationSettingsUpdate? + #[derive(Encode, Decode, Clone, Eq, PartialEq, RuntimeDebug, TypeInfo)] + pub struct SpaceModerationSettingsUpdate { + pub autoblock_threshold: Option> + } + + pub const FIRST_REPORT_ID: u64 = 1; + + + #[pallet::config] + pub trait Config: + frame_system::Config + + pallet_posts::Config + + pallet_spaces::Config + + pallet_space_follows::Config + { + /// The overarching event type. + type RuntimeEvent: From> + IsType<::RuntimeEvent>; + + #[pallet::constant] + type DefaultAutoblockThreshold: Get; + } + + + #[pallet::pallet] + #[pallet::generate_store(pub(super) trait Store)] + #[pallet::without_storage_info] + pub struct Pallet(_); + + #[pallet::type_value] + pub fn DefaultForNextReportId() -> PostId { + FIRST_REPORT_ID + } + + /// The next moderation report id. + #[pallet::storage] + #[pallet::getter(fn next_report_id)] + pub type NextReportId = StorageValue<_, ReportId, ValueQuery, DefaultForNextReportId>; + + /// Report details by its id (key). + #[pallet::storage] + #[pallet::getter(fn report_by_id)] + pub type ReportById = StorageMap<_, Twox64Concat, ReportId, Report>; + + /// Report id if entity (key 1) was reported by a specific account (key 2) + #[pallet::storage] + #[pallet::getter(fn report_id_by_account)] + pub type ReportIdByAccount = StorageMap<_, Twox64Concat, (EntityId, T::AccountId), ReportId>; + + /// Ids of all reports in this space (key). + #[pallet::storage] + #[pallet::getter(fn report_ids_by_space_id)] + pub type ReportIdsBySpaceId = StorageMap<_, Twox64Concat, SpaceId, Vec, ValueQuery>; + + /// Ids of all reports related to a specific entity (key 1) sent to this space (key 2). + #[pallet::storage] + #[pallet::getter(fn report_ids_by_entity_in_space)] + pub type ReportIdsByEntityInSpace = StorageDoubleMap<_, + Twox64Concat, + EntityId, + Twox64Concat, + SpaceId, + Vec, + ValueQuery, + >; + + /// An entity (key 1) status (`Blocked` or `Allowed`) in this space (key 2). + #[pallet::storage] + #[pallet::getter(fn status_by_entity_in_space)] + pub type StatusByEntityInSpace = StorageDoubleMap<_, + Twox64Concat, + EntityId, + Twox64Concat, + SpaceId, + EntityStatus, + >; + + /// Entity (key 1) statuses suggested by space (key 2) moderators. + #[pallet::storage] + #[pallet::getter(fn suggested_statuses)] + pub type SuggestedStatusesByEntityInSpace = StorageDoubleMap<_, + Twox64Concat, + EntityId, + Twox64Concat, + SpaceId, + Vec>, + ValueQuery, + >; + + /// A custom moderation settings for a certain space (key). + #[pallet::storage] + #[pallet::getter(fn moderation_settings)] + pub type ModerationSettings = StorageMap<_, Twox64Concat, SpaceId, SpaceModerationSettings>; + + // The pallet's events + #[pallet::event] + #[pallet::generate_deposit(pub (super) fn deposit_event)] + pub enum Event { + EntityReported(T::AccountId, SpaceId, EntityId, ReportId), + EntityStatusSuggested(T::AccountId, SpaceId, EntityId, Option), + EntityStatusUpdated(T::AccountId, SpaceId, EntityId, Option), + EntityStatusDeleted(T::AccountId, SpaceId, EntityId), + ModerationSettingsUpdated(T::AccountId, SpaceId), + } + + // The pallet's errors + #[pallet::error] + pub enum Error { + /// The account has already reported this entity. + AlreadyReportedEntity, + /// The entity has no status in this space. Nothing to delete. + EntityHasNoStatusInScope, + /// Entity scope differs from the scope provided. + EntityNotInScope, + /// Entity was not found by its id. + EntityNotFound, + /// Entity status is already as suggested one. + SuggestedSameEntityStatus, + /// Provided entity scope does not exist. + ScopeNotFound, + /// Account does not have a permission to suggest a new entity status. + NoPermissionToSuggestEntityStatus, + /// Account does not have a permission to update an entity status. + NoPermissionToUpdateEntityStatus, + /// Account does not have a permission to update the moderation settings. + NoPermissionToUpdateModerationSettings, + /// No updates provided for the space settings. + NoUpdatesForModerationSettings, + /// Report reason should not be empty. + ReasonIsEmpty, + /// Report was not found by its id. + ReportNotFound, + /// Trying to suggest an entity status in a scope that is different from the scope + /// the entity was reported in. + SuggestedStatusInWrongScope, + /// Entity status has already been suggested by this moderator account. + AlreadySuggestedEntityStatus, + } + + + #[pallet::call] + impl Pallet { + /// Report any entity by any person with mandatory reason. + /// `entity` scope and the `scope` provided mustn't differ + #[pallet::weight((Weight::from_ref_time(10_000) + T::DbWeight::get().reads_writes(6, 5)))] + pub fn report_entity( + origin: OriginFor, + entity: EntityId, + scope: SpaceId, + reason: Content + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // TODO check this func, if looks strange + ensure_content_is_some(&reason).map_err(|_| Error::::ReasonIsEmpty)?; + + ensure_content_is_valid(reason.clone())?; + + ensure!(Spaces::::require_space(scope).is_ok(), Error::::ScopeNotFound); + Self::ensure_entity_in_scope(&entity, scope)?; + + let not_reported_yet = Self::report_id_by_account((&entity, &who)).is_none(); + ensure!(not_reported_yet, Error::::AlreadyReportedEntity); + + let report_id = Self::next_report_id(); + let new_report = Report::::new(report_id, who.clone(), entity.clone(), scope, reason); + + ReportById::::insert(report_id, new_report); + ReportIdByAccount::::insert((&entity, &who), report_id); + ReportIdsBySpaceId::::mutate(scope, |ids| ids.push(report_id)); + ReportIdsByEntityInSpace::::mutate(&entity, scope, |ids| ids.push(report_id)); + NextReportId::::mutate(|n| { *n += 1; }); + + Self::deposit_event(Event::EntityReported(who, scope, entity, report_id)); + Ok(()) + } + + /// Leave a feedback on the report either it's confirmation or ignore. + /// `origin` - any permitted account (e.g. Space owner or moderator that's set via role) + #[pallet::weight(Weight::from_ref_time(10_000 /* TODO + T::DbWeight::get().reads_writes(_, _) */))] + pub fn suggest_entity_status( + origin: OriginFor, + entity: EntityId, + scope: SpaceId, // TODO make scope as Option, but either scope or report_id_opt should be Some + status: Option, + report_id_opt: Option + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + if let Some(report_id) = report_id_opt { + let report = Self::require_report(report_id)?; + ensure!(scope == report.reported_within, Error::::SuggestedStatusInWrongScope); + } + + let entity_status = StatusByEntityInSpace::::get(&entity, scope); + ensure!(!(entity_status.is_some() && status == entity_status), Error::::SuggestedSameEntityStatus); + + let space = Spaces::::require_space(scope).map_err(|_| Error::::ScopeNotFound)?; + Spaces::::ensure_account_has_space_permission( + who.clone(), + &space, + pallet_permissions::SpacePermission::SuggestEntityStatus, + Error::::NoPermissionToSuggestEntityStatus.into(), + )?; + + let mut suggestions = SuggestedStatusesByEntityInSpace::::get(&entity, scope); + let is_already_suggested = suggestions.iter().any(|suggestion| suggestion.suggested.account == who); + ensure!(!is_already_suggested, Error::::AlreadySuggestedEntityStatus); + suggestions.push(SuggestedStatus::new(who.clone(), status.clone(), report_id_opt)); + + let block_suggestions_total = suggestions.iter() + .filter(|suggestion| suggestion.status == Some(EntityStatus::Blocked)) + .count(); + + let autoblock_threshold_opt = Self::moderation_settings(scope) + .unwrap_or_else(Self::default_autoblock_threshold_as_settings) + .autoblock_threshold; + + if let Some(autoblock_threshold) = autoblock_threshold_opt { + if block_suggestions_total >= autoblock_threshold as usize { + Self::block_entity_in_scope(&entity, scope)?; + } + } + + SuggestedStatusesByEntityInSpace::::insert(entity.clone(), scope, suggestions); + + Self::deposit_event(Event::EntityStatusSuggested(who, scope, entity, status)); + Ok(()) + } + + /// Allows a space owner/admin to update the final moderation status of a reported entity. + #[pallet::weight(Weight::from_ref_time(10_000 /* TODO + T::DbWeight::get().reads_writes(_, _) */))] + pub fn update_entity_status( + origin: OriginFor, + entity: EntityId, + scope: SpaceId, + status_opt: Option + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + // TODO: add `forbid_content` parameter and track entity Content blocking via OCW + // - `forbid_content` - whether to block `Content` provided with entity. + + let space = Spaces::::require_space(scope).map_err(|_| Error::::ScopeNotFound)?; + Self::ensure_account_status_manager(who.clone(), &space)?; + + if let Some(status) = &status_opt { + let is_entity_in_scope = Self::ensure_entity_in_scope(&entity, scope).is_ok(); + + if is_entity_in_scope && status == &EntityStatus::Blocked { + Self::block_entity_in_scope(&entity, scope)?; + } else { + StatusByEntityInSpace::::insert(entity.clone(), scope, status); + } + } else { + StatusByEntityInSpace::::remove(entity.clone(), scope); + } + + Self::deposit_event(Event::EntityStatusUpdated(who, scope, entity, status_opt)); + Ok(()) + } + + /// Allows a space owner/admin to delete a current status of a reported entity. + #[pallet::weight(Weight::from_ref_time(10_000 /* TODO + T::DbWeight::get().reads_writes(_, _) */))] + pub fn delete_entity_status( + origin: OriginFor, + entity: EntityId, + scope: SpaceId + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let status = Self::status_by_entity_in_space(&entity, scope); + ensure!(status.is_some(), Error::::EntityHasNoStatusInScope); + + let space = Spaces::::require_space(scope).map_err(|_| Error::::ScopeNotFound)?; + Self::ensure_account_status_manager(who.clone(), &space)?; + + StatusByEntityInSpace::::remove(&entity, scope); + + Self::deposit_event(Event::EntityStatusDeleted(who, scope, entity)); + Ok(()) + } + + // todo: add ability to delete report_ids + + // TODO rename to update_settings? + #[pallet::weight(Weight::from_ref_time(10_000 /* TODO + T::DbWeight::get().reads_writes(_, _) */))] + pub fn update_moderation_settings( + origin: OriginFor, + space_id: SpaceId, + update: SpaceModerationSettingsUpdate + ) -> DispatchResult { + let who = ensure_signed(origin)?; + + let has_updates = update.autoblock_threshold.is_some(); + ensure!(has_updates, Error::::NoUpdatesForModerationSettings); + + let space = Spaces::::require_space(space_id)?; + + Spaces::::ensure_account_has_space_permission( + who.clone(), + &space, + pallet_permissions::SpacePermission::UpdateSpaceSettings, + Error::::NoPermissionToUpdateModerationSettings.into(), + )?; + + // `true` if there is at least one updated field. + let mut should_update = false; + + let mut settings = Self::moderation_settings(space_id) + .unwrap_or_else(Self::default_autoblock_threshold_as_settings); + + if let Some(autoblock_threshold) = update.autoblock_threshold { + if autoblock_threshold != settings.autoblock_threshold { + settings.autoblock_threshold = autoblock_threshold; + should_update = true; + } + } + + if should_update { + ModerationSettings::::insert(space_id, settings); + Self::deposit_event(Event::ModerationSettingsUpdated(who, space_id)); + } + Ok(()) + } + } +} \ No newline at end of file diff --git a/pallets/moderation/src/mock.rs b/pallets/moderation/src/mock.rs new file mode 100644 index 00000000..6d0f26a8 --- /dev/null +++ b/pallets/moderation/src/mock.rs @@ -0,0 +1,389 @@ +use super::*; + +use crate as moderation; + +use frame_support::{assert_ok, dispatch::DispatchResult, parameter_types, StorageMap, traits::Everything}; +use frame_system as system; + +use sp_core::{ConstU32, H256}; +use sp_io::TestExternalities; +use sp_runtime::{ + testing::Header, + traits::{BlakeTwo256, IdentityLookup}, +}; +use sp_std::convert::TryInto; +use sp_std::convert::TryFrom; +use pallet_permissions::{ + SpacePermission as SP, + default_permissions::DefaultSpacePermissions, +}; +use pallet_posts::PostExtension; +use pallet_roles::RoleId; +use pallet_spaces::{types::RESERVED_SPACE_COUNT, SpaceById}; +use subsocial_support::User; +use subsocial_support::mock_functions::valid_content_ipfs; + +type UncheckedExtrinsic = frame_system::mocking::MockUncheckedExtrinsic; +type Block = frame_system::mocking::MockBlock; + +frame_support::construct_runtime!( + pub enum Test where + Block = Block, + NodeBlock = Block, + UncheckedExtrinsic = UncheckedExtrinsic, + { + System: system::{Pallet, Call, Config, Storage, Event}, + Balances: pallet_balances::{Pallet, Call, Storage, Config, Event}, + Moderation: moderation::{Pallet, Call, Storage, Event}, + Posts: pallet_posts::{Pallet, Call, Storage, Event}, + Roles: pallet_roles::{Pallet, Call, Storage, Event}, + SpaceFollows: pallet_space_follows::{Pallet, Call, Storage, Event}, + Spaces: pallet_spaces::{Pallet, Call, Storage, Event, Config}, + Timestamp: pallet_timestamp, + } +); + +parameter_types! { + pub const BlockHashCount: u64 = 250; +} + +impl system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type Index = u64; + type BlockNumber = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId; + type Lookup = IdentityLookup; + type Header = Header; + type RuntimeEvent = RuntimeEvent; + type BlockHashCount = BlockHashCount; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = frame_support::traits::ConstU32<16>; +} + +parameter_types! { + pub const ExistentialDeposit: u64 = 1; +} + +impl pallet_balances::Config for Test { + type Balance = u64; + type DustRemoval = (); + type RuntimeEvent = RuntimeEvent; + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type WeightInfo = (); + type MaxLocks = (); + type MaxReserves = (); + type ReserveIdentifier = (); +} + +impl pallet_permissions::Config for Test { + type DefaultSpacePermissions = DefaultSpacePermissions; +} + +impl pallet_spaces::Config for Test { + type RuntimeEvent = RuntimeEvent; + type Roles = Roles; + type SpaceFollows = SpaceFollows; + type IsAccountBlocked = Moderation; + type IsContentBlocked = Moderation; + type MaxSpacesPerAccount = ConstU32<200>; + type WeightInfo = (); +} + +impl pallet_space_follows::Config for Test { + type RuntimeEvent = RuntimeEvent; + type WeightInfo = (); +} + +parameter_types! { + pub const MaxCommentDepth: u32 = 10; +} + +parameter_types! { + pub const MinimumPeriod: u64 = 5; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = MinimumPeriod; + type WeightInfo = (); +} + +impl pallet_posts::Config for Test { + type RuntimeEvent = RuntimeEvent; + type MaxCommentDepth = MaxCommentDepth; + type IsPostBlocked = Moderation; + type WeightInfo = (); +} + +parameter_types! { + pub const MaxUsersToProcessPerDeleteRole: u16 = 40; +} + +impl pallet_roles::Config for Test { + type RuntimeEvent = RuntimeEvent; + type MaxUsersToProcessPerDeleteRole = MaxUsersToProcessPerDeleteRole; + type SpaceFollows = SpaceFollows; + type IsAccountBlocked = Moderation; + type IsContentBlocked = Moderation; + type SpacePermissionsProvider = Spaces; + type WeightInfo = (); +} + +parameter_types! { + pub const DefaultAutoblockThreshold: u16 = 3; +} + +impl Config for Test { + type RuntimeEvent = RuntimeEvent; + type DefaultAutoblockThreshold = DefaultAutoblockThreshold; +} + +pub(crate) type AccountId = u64; + +pub struct ExtBuilder; + +impl ExtBuilder { + pub fn build() -> TestExternalities { + let storage = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| System::set_block_number(1)); + + ext + } + + pub fn build_with_space_and_post() -> TestExternalities { + let storage = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + System::set_block_number(1); + create_space_and_post(); + }); + + ext + } + + pub fn build_with_space_and_post_then_report() -> TestExternalities { + let storage = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + System::set_block_number(1); + + create_space_and_post(); + assert_ok!(_report_default_post()); + }); + + ext + } + + pub fn build_with_report_then_remove_scope() -> TestExternalities { + let storage = system::GenesisConfig::default() + .build_storage::() + .unwrap(); + + let mut ext = TestExternalities::from(storage); + ext.execute_with(|| { + System::set_block_number(1); + + create_space_and_post(); + assert_ok!(_report_default_post()); + + SpaceById::::remove(SPACE1); + }); + + ext + } + + pub fn build_with_report_then_grant_role_to_suggest_entity_status() -> TestExternalities { + let mut ext = Self::build_with_space_and_post_then_report(); + + ext.execute_with(|| { + // Create a new role for moderators: + assert_ok!(Roles::create_role( + RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER), + SPACE1, + None, + default_role_content_ipfs(), + vec![SP::SuggestEntityStatus], + )); + + // Allow the moderator accounts to suggest entity statuses: + let mods = moderators().into_iter().map(User::Account).collect(); + assert_ok!(Roles::grant_role( + RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER), + MODERATOR_ROLE_ID, + mods + )); + }); + + ext + } +} + +pub(crate) const ACCOUNT_SCOPE_OWNER: AccountId = 1; +pub(crate) const ACCOUNT_NOT_MODERATOR: AccountId = 2; +pub(crate) const FIRST_MODERATOR_ID: AccountId = 100; + +pub(crate) const SPACE1: SpaceId = RESERVED_SPACE_COUNT + 1; +pub(crate) const SPACE2: SpaceId = SPACE1 + 1; + +pub(crate) const POST1: PostId = 1; + +pub(crate) const REPORT1: ReportId = 1; +pub(crate) const REPORT2: ReportId = 2; + +pub(crate) const MODERATOR_ROLE_ID: RoleId = 1; + +pub(crate) const AUTOBLOCK_THRESHOLD: u16 = 5; + +pub(crate) const fn new_autoblock_threshold() -> SpaceModerationSettingsUpdate { + SpaceModerationSettingsUpdate { + autoblock_threshold: Some(Some(AUTOBLOCK_THRESHOLD)) + } +} + +pub(crate) const fn empty_moderation_settings_update() -> SpaceModerationSettingsUpdate { + SpaceModerationSettingsUpdate { + autoblock_threshold: None + } +} + +pub(crate) fn moderators() -> Vec { + let first_mod_id = FIRST_MODERATOR_ID; + let last_mod_id = first_mod_id + DefaultAutoblockThreshold::get() as u64 + 2; + (first_mod_id..last_mod_id).collect() +} + +// TODO: replace with common function when benchmarks PR is merged +// TODO: replace with common function when benchmarks PR is merged +pub(crate) fn default_role_content_ipfs() -> Content { + Content::IPFS(b"QmRAQB6YaCyidP37UdDnjFY5vQuiBrcqdyoW1CuDgwxkD4".to_vec()) +} + +pub(crate) fn create_space_and_post() { + assert_ok!(Spaces::create_space( + RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER), + Content::None, + None + )); + + assert_ok!(Posts::create_post( + RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER), + Some(SPACE1), + PostExtension::RegularPost, + valid_content_ipfs(), + )); +} + +pub(crate) fn _report_default_post() -> DispatchResult { + _report_entity(None, None, None, None) +} + +pub(crate) fn _report_entity( + origin: Option, + entity: Option>, + scope: Option, + reason: Option, +) -> DispatchResult { + Moderation::report_entity( + origin.unwrap_or_else(|| RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER)), + entity.unwrap_or(EntityId::Post(POST1)), + scope.unwrap_or(SPACE1), + reason.unwrap_or_else(valid_content_ipfs), + ) +} + +pub(crate) fn _suggest_blocked_status_for_post() -> DispatchResult { + _suggest_entity_status(None, None, None, None, None) +} + +/// By default (when all options are `None`) makes ACCOUNT1 to suggest 'Blocked' status to the POST1 +pub(crate) fn _suggest_entity_status( + origin: Option, + entity: Option>, + scope: Option, + status: Option>, + report_id_opt: Option>, +) -> DispatchResult { + Moderation::suggest_entity_status( + origin.unwrap_or_else(|| RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER)), + entity.unwrap_or(EntityId::Post(POST1)), + scope.unwrap_or(SPACE1), + status.unwrap_or(Some(EntityStatus::Blocked)), + report_id_opt.unwrap_or(Some(REPORT1)), + ) +} + +pub(crate) fn _update_post_status_to_allowed() -> DispatchResult { + _update_entity_status(None, None, None, None) +} + +pub(crate) fn _update_entity_status( + origin: Option, + entity: Option>, + scope: Option, + status_opt: Option>, +) -> DispatchResult { + Moderation::update_entity_status( + origin.unwrap_or_else(|| RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER)), + entity.unwrap_or(EntityId::Post(POST1)), + scope.unwrap_or(SPACE1), + status_opt.unwrap_or(Some(EntityStatus::Allowed)), + ) +} + +pub(crate) fn _delete_post_status() -> DispatchResult { + _delete_entity_status(None, None, None) +} + +pub(crate) fn _delete_entity_status( + origin: Option, + entity: Option>, + scope: Option, +) -> DispatchResult { + Moderation::delete_entity_status( + origin.unwrap_or_else(|| RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER)), + entity.unwrap_or(EntityId::Post(POST1)), + scope.unwrap_or(SPACE1), + ) +} + +pub(crate) fn _update_autoblock_threshold_in_moderation_settings() -> DispatchResult { + _update_moderation_settings(None, None, None) +} + +pub(crate) fn _update_moderation_settings( + origin: Option, + space_id: Option, + settings_update: Option, +) -> DispatchResult { + Moderation::update_moderation_settings( + origin.unwrap_or_else(|| RuntimeOrigin::signed(ACCOUNT_SCOPE_OWNER)), + space_id.unwrap_or(SPACE1), + settings_update.unwrap_or_else(new_autoblock_threshold), + ) +} \ No newline at end of file diff --git a/pallets/moderation/src/tests.rs b/pallets/moderation/src/tests.rs new file mode 100644 index 00000000..4bb799a0 --- /dev/null +++ b/pallets/moderation/src/tests.rs @@ -0,0 +1,333 @@ +use crate::{Error, mock::*}; +use crate::*; + +use frame_support::{assert_ok, assert_noop}; +use pallet_posts::PostById; +use pallet_spaces::{SpaceById, Error as SpaceError}; +use subsocial_support::ContentError; +use subsocial_support::mock_functions::{valid_content_ipfs, invalid_content_ipfs}; + +#[test] +fn report_entity_should_work() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_eq!(Moderation::next_report_id(), REPORT2); + + let report = Moderation::report_by_id(REPORT1).unwrap(); + assert_eq!(report.id, REPORT1); + assert_eq!(report.created.account, ACCOUNT_SCOPE_OWNER); + assert_eq!(report.reported_entity, EntityId::Post(POST1)); + assert_eq!(report.reported_within, SPACE1); + assert_eq!(report.reason, valid_content_ipfs()); + }); +} + +#[test] +fn report_entity_should_fail_when_no_reason_provided() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_noop!( + _report_entity( + None, + None, + None, + Some(Content::None) + ), Error::::ReasonIsEmpty + ); + }); +} + +#[test] +fn report_entity_should_fail_when_reason_is_invalid_ipfs_cid() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_noop!( + _report_entity( + None, + None, + None, + Some(invalid_content_ipfs()) + ), ContentError::InvalidIpfsCid + ); + }); +} + +#[test] +fn report_entity_should_fail_when_invalid_scope_provided() { + ExtBuilder::build().execute_with(|| { + assert_noop!(_report_default_post(), Error::::ScopeNotFound); + }); +} + +#[test] +fn report_entity_should_fail_when_entity_already_reported() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_noop!(_report_default_post(), Error::::AlreadyReportedEntity); + }); +} + +// Suggest entity status +//------------------------------------------------------------------------- + +#[test] +fn suggest_entity_status_should_work() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + + let suggestions = Moderation::suggested_statuses(EntityId::Post(POST1), SPACE1); + let expected_status = SuggestedStatus::::new( + ACCOUNT_SCOPE_OWNER, + Some(EntityStatus::Blocked), + Some(REPORT1), + ); + + assert!(suggestions == vec![expected_status]); + }); +} + +#[test] +fn suggest_entity_status_should_fail_when_report_not_found() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_noop!( + _suggest_entity_status( + None, + None, + None, + None, + Some(Some(REPORT2)) + ), Error::::ReportNotFound + ); + }); +} + +#[test] +fn suggest_entity_status_should_fail_when_report_in_another_scope() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_noop!( + _suggest_entity_status( + None, + None, + Some(SPACE2), + None, + None + ), Error::::SuggestedStatusInWrongScope + ); + }); +} + +#[test] +fn suggest_entity_status_should_fail_when_same_entity_status_already_suggested() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!(_update_post_status_to_allowed()); + assert_noop!( + _suggest_entity_status( + None, + None, + None, + Some(Some(EntityStatus::Allowed)), + None + ), Error::::SuggestedSameEntityStatus + ); + }); +} + +#[test] +fn suggest_entity_status_should_fail_when_scope_not_found() { + ExtBuilder::build_with_report_then_remove_scope().execute_with(|| { + assert_noop!(_suggest_blocked_status_for_post(), Error::::ScopeNotFound); + }); +} + +#[test] +fn suggest_entity_status_should_fail_when_origin_has_no_permission() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_noop!( + _suggest_entity_status( + Some(RuntimeOrigin::signed(ACCOUNT_NOT_MODERATOR)), + None, + None, + None, + None + ), Error::::NoPermissionToSuggestEntityStatus + ); + }); +} + +#[test] +fn suggest_entity_status_should_autoblock_and_kick_entity_when_threshold_reached() { + ExtBuilder::build_with_report_then_grant_role_to_suggest_entity_status().execute_with(|| { + let space_before_autoblock = Spaces::::space_by_id(SPACE1).unwrap(); + let post_before_autoblock = Posts::post_by_id(POST1).unwrap(); + + assert!(post_before_autoblock.space_id == Some(SPACE1)); + assert_eq!(Posts::post_ids_by_space_id(SPACE1), vec![POST1]); + + // All accounts that have the corresponding role suggest entity status 'Blocked'. + let accs = moderators(); + for (i, acc) in accs.into_iter().enumerate() { + let res = _suggest_entity_status(Some(RuntimeOrigin::signed(acc)), None, None, None, None); + if (i as u16) < DefaultAutoblockThreshold::get() { + assert_ok!(res); + } else { + assert_noop!(res, Error::::SuggestedSameEntityStatus); + } + } + + let space_after_autoblock = Spaces::::space_by_id(SPACE1).unwrap(); + let post_after_autoblock = Posts::post_by_id(POST1).unwrap(); + + assert!(post_after_autoblock.space_id.is_none()); + assert!(Posts::post_ids_by_space_id(SPACE1).is_empty()); + }); +} + +// Update entity status +//---------------------------------------------------------------------------- + +#[test] +fn update_entity_status_should_work_for_status_allowed() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!(_update_post_status_to_allowed()); + + let status = Moderation::status_by_entity_in_space(EntityId::Post(POST1), SPACE1).unwrap(); + assert_eq!(status, EntityStatus::Allowed); + }); +} + +#[test] +fn update_entity_status_should_work_for_status_blocked() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!( + _update_entity_status( + None, + None, + None, + Some(Some(EntityStatus::Blocked)) + ) + ); + + // Check that post was removed from its space, + // because when removing a post, we set its space to None + let post = PostById::::get(POST1).unwrap(); + assert!(post.space_id.is_none()); + }); +} + +#[test] +fn update_entity_status_should_fail_when_invalid_scope_provided() { + ExtBuilder::build_with_report_then_remove_scope().execute_with(|| { + assert_noop!(_update_post_status_to_allowed(), Error::::ScopeNotFound); + }); +} + +#[test] +fn update_entity_status_should_fail_when_origin_has_no_permission() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_noop!( + _update_entity_status( + Some(RuntimeOrigin::signed(ACCOUNT_NOT_MODERATOR)), + None, + None, + None + ), Error::::NoPermissionToUpdateEntityStatus + ); + }); +} + +// Delete entity status +//--------------------------------------------------------------------------- + +#[test] +fn delete_entity_status_should_work() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!(_update_post_status_to_allowed()); + assert_ok!(_delete_post_status()); + + let status = Moderation::status_by_entity_in_space(EntityId::Post(POST1), SPACE1); + assert!(status.is_none()); + }); +} + +#[test] +fn delete_entity_status_should_fail_when_entity_has_no_status_in_scope() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_noop!(_delete_post_status(), Error::::EntityHasNoStatusInScope); + }); +} + +#[test] +fn delete_entity_status_should_fail_when_scope_not_found() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!(_update_post_status_to_allowed()); + SpaceById::::remove(SPACE1); + assert_noop!(_delete_post_status(), Error::::ScopeNotFound); + }); +} + +#[test] +fn delete_entity_status_should_fail_when_origin_has_no_permission() { + ExtBuilder::build_with_space_and_post_then_report().execute_with(|| { + assert_ok!(_suggest_blocked_status_for_post()); + assert_ok!(_update_post_status_to_allowed()); + assert_noop!( + _delete_entity_status( + Some(RuntimeOrigin::signed(ACCOUNT_NOT_MODERATOR)), + None, + None + ), Error::::NoPermissionToUpdateEntityStatus + ); + }); +} + +// Update moderation settings +//---------------------------------------------------------------------------- + +#[test] +fn update_moderation_settings_should_work() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_ok!(_update_autoblock_threshold_in_moderation_settings()); + + let settings = Moderation::moderation_settings(SPACE1).unwrap(); + assert_eq!(settings.autoblock_threshold, Some(AUTOBLOCK_THRESHOLD)); + }); +} + +// TODO test that autoblock works + +#[test] +fn update_moderation_settings_should_fail_when_no_updates_provided() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_noop!( + _update_moderation_settings( + None, + None, + Some(empty_moderation_settings_update()) + ), Error::::NoUpdatesForModerationSettings + ); + }); +} + +#[test] +fn update_moderation_settings_should_fail_when_space_not_found() { + ExtBuilder::build_with_report_then_remove_scope().execute_with(|| { + assert_noop!( + _update_autoblock_threshold_in_moderation_settings(), + SpaceError::::SpaceNotFound + ); + }); +} + +#[test] +fn update_moderation_settings_should_fail_when_origin_has_no_permission() { + ExtBuilder::build_with_space_and_post().execute_with(|| { + assert_noop!( + _update_moderation_settings( + Some(RuntimeOrigin::signed(ACCOUNT_NOT_MODERATOR)), + None, + None + ), Error::::NoPermissionToUpdateModerationSettings + ); + }); +} \ No newline at end of file diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 2a76293e..26f8cc7a 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -35,6 +35,7 @@ pallet-roles = { path = '../pallets/roles', default-features = false } pallet-space-follows = { path = '../pallets/space-follows', default-features = false } pallet-space-ownership = { path = '../pallets/space-ownership', default-features = false } pallet-spaces = { path = '../pallets/spaces', default-features = false } +pallet-moderation = { path = '../pallets/moderation', default-features = false } pallet-free-proxy = { path = "../pallets/free-proxy", default-features = false } # Substrate @@ -155,6 +156,7 @@ std = [ "pallet-space-ownership/std", "pallet-spaces/std", "pallet-free-proxy/std", + "pallet-moderation/std", ] runtime-benchmarks = [ @@ -185,6 +187,7 @@ runtime-benchmarks = [ "pallet-posts/runtime-benchmarks", "pallet-profiles/runtime-benchmarks", "pallet-free-proxy/runtime-benchmarks", + "pallet-moderation/runtime-benchmarks", ] try-runtime = [ @@ -222,4 +225,5 @@ try-runtime = [ "pallet-space-follows/try-runtime", "pallet-space-ownership/try-runtime", "pallet-spaces/try-runtime", + "pallet-moderation/try-runtime", ] diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index c8dcb0c3..154b5e91 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -725,6 +725,15 @@ impl pallet_energy::Config for Runtime { type WeightInfo = pallet_energy::weights::SubstrateWeight; } +parameter_types! { + pub const DefaultAutoblockThreshold: u16 = 20; +} + +impl pallet_moderation::Config for Runtime { + type RuntimeEvent = RuntimeEvent; + type DefaultAutoblockThreshold = DefaultAutoblockThreshold; +} + // Create the runtime by composing the FRAME pallets that were previously configured. construct_runtime!( pub enum Runtime where @@ -766,6 +775,7 @@ construct_runtime!( Domains: pallet_domains = 60, Energy: pallet_energy = 61, FreeProxy: pallet_free_proxy = 62, + Moderation: pallet_moderation = 63, Permissions: pallet_permissions = 70, Roles: pallet_roles = 71,