2026-01-27 16:21:58 +00:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
use async_trait::async_trait;
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use serde_json::{json, Value};
|
|
|
|
|
use sqlx::PgPool;
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
use crate::plugins::{
|
|
|
|
|
hooks::{HookContext, PluginError},
|
|
|
|
|
manager::{Plugin, PluginMetadata, PluginScope, PluginSystem},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Moderation Ledger Plugin
|
|
|
|
|
///
|
|
|
|
|
/// Creates an immutable, cryptographically-chained log of all moderation decisions.
|
|
|
|
|
/// This plugin is NON-DEACTIVATABLE by design - transparency is not optional.
|
|
|
|
|
///
|
|
|
|
|
/// Features:
|
|
|
|
|
/// - Immutable entries with SHA-256 hash chain
|
|
|
|
|
/// - Full audit trail with actor, target, reason, evidence
|
|
|
|
|
/// - Chain verification for tamper detection
|
|
|
|
|
/// - Export to JSON/CSV
|
|
|
|
|
/// - Community-scoped with independent chains
|
|
|
|
|
pub struct ModerationLedgerPlugin;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
#[serde(rename_all = "snake_case")]
|
|
|
|
|
pub enum ModerationActionType {
|
|
|
|
|
// Content moderation
|
|
|
|
|
ContentRemove,
|
|
|
|
|
ContentHide,
|
|
|
|
|
ContentRestore,
|
|
|
|
|
ContentEdit,
|
|
|
|
|
ContentFlag,
|
|
|
|
|
ContentUnflag,
|
|
|
|
|
|
|
|
|
|
// User moderation
|
|
|
|
|
UserWarn,
|
|
|
|
|
UserMute,
|
|
|
|
|
UserUnmute,
|
|
|
|
|
UserSuspend,
|
|
|
|
|
UserUnsuspend,
|
|
|
|
|
UserBan,
|
|
|
|
|
UserUnban,
|
|
|
|
|
UserRoleChange,
|
|
|
|
|
|
|
|
|
|
// Community moderation
|
|
|
|
|
CommunitySettingChange,
|
|
|
|
|
CommunityRuleAdd,
|
|
|
|
|
CommunityRuleEdit,
|
|
|
|
|
CommunityRuleRemove,
|
|
|
|
|
|
|
|
|
|
// Proposal/voting moderation
|
|
|
|
|
ProposalClose,
|
|
|
|
|
ProposalReopen,
|
|
|
|
|
ProposalArchive,
|
|
|
|
|
VoteInvalidate,
|
|
|
|
|
VoteRestore,
|
|
|
|
|
|
|
|
|
|
// Escalation
|
|
|
|
|
EscalateToAdmin,
|
|
|
|
|
EscalateToCommunity,
|
|
|
|
|
AppealReceived,
|
|
|
|
|
AppealResolved,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ModerationActionType {
|
|
|
|
|
pub fn as_db_str(&self) -> &'static str {
|
|
|
|
|
match self {
|
|
|
|
|
Self::ContentRemove => "content_remove",
|
|
|
|
|
Self::ContentHide => "content_hide",
|
|
|
|
|
Self::ContentRestore => "content_restore",
|
|
|
|
|
Self::ContentEdit => "content_edit",
|
|
|
|
|
Self::ContentFlag => "content_flag",
|
|
|
|
|
Self::ContentUnflag => "content_unflag",
|
|
|
|
|
Self::UserWarn => "user_warn",
|
|
|
|
|
Self::UserMute => "user_mute",
|
|
|
|
|
Self::UserUnmute => "user_unmute",
|
|
|
|
|
Self::UserSuspend => "user_suspend",
|
|
|
|
|
Self::UserUnsuspend => "user_unsuspend",
|
|
|
|
|
Self::UserBan => "user_ban",
|
|
|
|
|
Self::UserUnban => "user_unban",
|
|
|
|
|
Self::UserRoleChange => "user_role_change",
|
|
|
|
|
Self::CommunitySettingChange => "community_setting_change",
|
|
|
|
|
Self::CommunityRuleAdd => "community_rule_add",
|
|
|
|
|
Self::CommunityRuleEdit => "community_rule_edit",
|
|
|
|
|
Self::CommunityRuleRemove => "community_rule_remove",
|
|
|
|
|
Self::ProposalClose => "proposal_close",
|
|
|
|
|
Self::ProposalReopen => "proposal_reopen",
|
|
|
|
|
Self::ProposalArchive => "proposal_archive",
|
|
|
|
|
Self::VoteInvalidate => "vote_invalidate",
|
|
|
|
|
Self::VoteRestore => "vote_restore",
|
|
|
|
|
Self::EscalateToAdmin => "escalate_to_admin",
|
|
|
|
|
Self::EscalateToCommunity => "escalate_to_community",
|
|
|
|
|
Self::AppealReceived => "appeal_received",
|
|
|
|
|
Self::AppealResolved => "appeal_resolved",
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct LedgerEntry {
|
|
|
|
|
pub id: Uuid,
|
|
|
|
|
pub sequence_number: i64,
|
|
|
|
|
pub community_id: Option<Uuid>,
|
|
|
|
|
pub actor_user_id: Uuid,
|
|
|
|
|
pub actor_role: String,
|
|
|
|
|
pub action_type: String,
|
|
|
|
|
pub target_type: String,
|
|
|
|
|
pub target_id: Uuid,
|
|
|
|
|
pub reason: String,
|
|
|
|
|
pub rule_reference: Option<String>,
|
|
|
|
|
pub evidence: Option<Value>,
|
|
|
|
|
pub duration_hours: Option<i32>,
|
|
|
|
|
pub decision_type: String,
|
|
|
|
|
pub entry_hash: String,
|
|
|
|
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct ChainVerificationResult {
|
|
|
|
|
pub is_valid: bool,
|
|
|
|
|
pub total_entries: i64,
|
|
|
|
|
pub broken_at_sequence: Option<i64>,
|
|
|
|
|
pub expected_hash: Option<String>,
|
|
|
|
|
pub actual_hash: Option<String>,
|
|
|
|
|
pub error_message: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Service for interacting with the moderation ledger
|
|
|
|
|
pub struct LedgerService;
|
|
|
|
|
|
|
|
|
|
impl LedgerService {
|
|
|
|
|
/// Create a new ledger entry
|
2026-01-28 23:46:43 +00:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
2026-01-27 16:21:58 +00:00
|
|
|
pub async fn create_entry(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
community_id: Option<Uuid>,
|
|
|
|
|
actor_user_id: Uuid,
|
|
|
|
|
actor_role: &str,
|
|
|
|
|
action_type: ModerationActionType,
|
|
|
|
|
target_type: &str,
|
|
|
|
|
target_id: Uuid,
|
|
|
|
|
reason: &str,
|
|
|
|
|
rule_reference: Option<&str>,
|
|
|
|
|
evidence: Option<Value>,
|
|
|
|
|
target_snapshot: Option<Value>,
|
|
|
|
|
duration_hours: Option<i32>,
|
|
|
|
|
decision_type: &str,
|
|
|
|
|
vote_proposal_id: Option<Uuid>,
|
|
|
|
|
vote_result: Option<Value>,
|
|
|
|
|
) -> Result<Uuid, PluginError> {
|
|
|
|
|
let entry_id: Uuid = sqlx::query_scalar(
|
|
|
|
|
r#"SELECT create_ledger_entry(
|
|
|
|
|
$1, $2, $3, $4::moderation_action_type, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
|
|
|
|
)"#,
|
|
|
|
|
)
|
|
|
|
|
.bind(community_id)
|
|
|
|
|
.bind(actor_user_id)
|
|
|
|
|
.bind(actor_role)
|
|
|
|
|
.bind(action_type.as_db_str())
|
|
|
|
|
.bind(target_type)
|
|
|
|
|
.bind(target_id)
|
|
|
|
|
.bind(reason)
|
|
|
|
|
.bind(rule_reference)
|
|
|
|
|
.bind(evidence)
|
|
|
|
|
.bind(target_snapshot)
|
|
|
|
|
.bind(duration_hours)
|
|
|
|
|
.bind(decision_type)
|
|
|
|
|
.bind(vote_proposal_id)
|
|
|
|
|
.bind(vote_result)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(entry_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get ledger entries for a community
|
|
|
|
|
pub async fn get_entries(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
community_id: Option<Uuid>,
|
|
|
|
|
limit: i64,
|
|
|
|
|
offset: i64,
|
|
|
|
|
) -> Result<Vec<LedgerEntry>, PluginError> {
|
|
|
|
|
let entries = sqlx::query_as!(
|
|
|
|
|
LedgerEntry,
|
|
|
|
|
r#"SELECT
|
|
|
|
|
id,
|
|
|
|
|
sequence_number,
|
|
|
|
|
community_id,
|
|
|
|
|
actor_user_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
action_type::text AS "action_type!",
|
|
|
|
|
target_type,
|
|
|
|
|
target_id,
|
|
|
|
|
reason,
|
|
|
|
|
rule_reference,
|
|
|
|
|
evidence,
|
|
|
|
|
duration_hours,
|
|
|
|
|
decision_type,
|
|
|
|
|
entry_hash,
|
|
|
|
|
created_at
|
|
|
|
|
FROM moderation_ledger
|
|
|
|
|
WHERE community_id IS NOT DISTINCT FROM $1
|
|
|
|
|
ORDER BY sequence_number DESC
|
|
|
|
|
LIMIT $2 OFFSET $3"#,
|
|
|
|
|
community_id,
|
|
|
|
|
limit,
|
|
|
|
|
offset,
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(entries)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get a single entry by ID
|
|
|
|
|
pub async fn get_entry(pool: &PgPool, entry_id: Uuid) -> Result<Option<LedgerEntry>, PluginError> {
|
|
|
|
|
let entry = sqlx::query_as!(
|
|
|
|
|
LedgerEntry,
|
|
|
|
|
r#"SELECT
|
|
|
|
|
id,
|
|
|
|
|
sequence_number,
|
|
|
|
|
community_id,
|
|
|
|
|
actor_user_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
action_type::text AS "action_type!",
|
|
|
|
|
target_type,
|
|
|
|
|
target_id,
|
|
|
|
|
reason,
|
|
|
|
|
rule_reference,
|
|
|
|
|
evidence,
|
|
|
|
|
duration_hours,
|
|
|
|
|
decision_type,
|
|
|
|
|
entry_hash,
|
|
|
|
|
created_at
|
|
|
|
|
FROM moderation_ledger
|
|
|
|
|
WHERE id = $1"#,
|
|
|
|
|
entry_id,
|
|
|
|
|
)
|
|
|
|
|
.fetch_optional(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(entry)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get entries targeting a specific entity
|
|
|
|
|
pub async fn get_entries_for_target(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
target_type: &str,
|
|
|
|
|
target_id: Uuid,
|
|
|
|
|
) -> Result<Vec<LedgerEntry>, PluginError> {
|
|
|
|
|
let entries = sqlx::query_as!(
|
|
|
|
|
LedgerEntry,
|
|
|
|
|
r#"SELECT
|
|
|
|
|
id,
|
|
|
|
|
sequence_number,
|
|
|
|
|
community_id,
|
|
|
|
|
actor_user_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
action_type::text AS "action_type!",
|
|
|
|
|
target_type,
|
|
|
|
|
target_id,
|
|
|
|
|
reason,
|
|
|
|
|
rule_reference,
|
|
|
|
|
evidence,
|
|
|
|
|
duration_hours,
|
|
|
|
|
decision_type,
|
|
|
|
|
entry_hash,
|
|
|
|
|
created_at
|
|
|
|
|
FROM moderation_ledger
|
|
|
|
|
WHERE target_type = $1 AND target_id = $2
|
|
|
|
|
ORDER BY sequence_number DESC"#,
|
|
|
|
|
target_type,
|
|
|
|
|
target_id,
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(entries)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verify the chain integrity
|
|
|
|
|
pub async fn verify_chain(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
community_id: Option<Uuid>,
|
|
|
|
|
) -> Result<ChainVerificationResult, PluginError> {
|
|
|
|
|
let result = sqlx::query!(
|
|
|
|
|
r#"SELECT
|
|
|
|
|
is_valid,
|
|
|
|
|
total_entries,
|
|
|
|
|
broken_at_sequence,
|
|
|
|
|
expected_hash,
|
|
|
|
|
actual_hash,
|
|
|
|
|
error_message
|
|
|
|
|
FROM verify_ledger_chain($1)"#,
|
|
|
|
|
community_id,
|
|
|
|
|
)
|
|
|
|
|
.fetch_one(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
Ok(ChainVerificationResult {
|
|
|
|
|
is_valid: result.is_valid.unwrap_or(false),
|
|
|
|
|
total_entries: result.total_entries.unwrap_or(0),
|
|
|
|
|
broken_at_sequence: result.broken_at_sequence,
|
|
|
|
|
expected_hash: result.expected_hash,
|
|
|
|
|
actual_hash: result.actual_hash,
|
|
|
|
|
error_message: result.error_message,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Get statistics for a community
|
|
|
|
|
pub async fn get_stats(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
community_id: Option<Uuid>,
|
|
|
|
|
) -> Result<Value, PluginError> {
|
|
|
|
|
let stats = sqlx::query!(
|
|
|
|
|
r#"SELECT
|
|
|
|
|
action_type::text AS action_type,
|
|
|
|
|
decision_type,
|
|
|
|
|
total_actions,
|
|
|
|
|
unique_actors,
|
|
|
|
|
unique_targets
|
|
|
|
|
FROM v_moderation_stats
|
|
|
|
|
WHERE community_id IS NOT DISTINCT FROM $1"#,
|
|
|
|
|
community_id,
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(pool)
|
|
|
|
|
.await?;
|
|
|
|
|
|
|
|
|
|
let summary: Vec<Value> = stats
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|s| {
|
|
|
|
|
json!({
|
|
|
|
|
"action_type": s.action_type,
|
|
|
|
|
"decision_type": s.decision_type,
|
|
|
|
|
"total_actions": s.total_actions,
|
|
|
|
|
"unique_actors": s.unique_actors,
|
|
|
|
|
"unique_targets": s.unique_targets,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(json!({ "stats": summary }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Export ledger entries as JSON
|
|
|
|
|
pub async fn export_json(
|
|
|
|
|
pool: &PgPool,
|
|
|
|
|
community_id: Option<Uuid>,
|
|
|
|
|
) -> Result<Value, PluginError> {
|
|
|
|
|
let entries = Self::get_entries(pool, community_id, 100000, 0).await?;
|
|
|
|
|
let verification = Self::verify_chain(pool, community_id).await?;
|
|
|
|
|
|
|
|
|
|
Ok(json!({
|
|
|
|
|
"export_version": "1.0",
|
|
|
|
|
"exported_at": chrono::Utc::now(),
|
|
|
|
|
"community_id": community_id,
|
|
|
|
|
"chain_verification": verification,
|
|
|
|
|
"entries": entries,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[async_trait]
|
|
|
|
|
impl Plugin for ModerationLedgerPlugin {
|
|
|
|
|
fn metadata(&self) -> PluginMetadata {
|
|
|
|
|
PluginMetadata {
|
|
|
|
|
name: "moderation_ledger",
|
|
|
|
|
version: "1.0.0",
|
|
|
|
|
description: "Immutable, cryptographically-chained log of all moderation decisions. Cannot be deactivated.",
|
|
|
|
|
is_core: true, // Core plugin - cannot be disabled
|
|
|
|
|
scope: PluginScope::Global,
|
|
|
|
|
default_enabled: true,
|
|
|
|
|
settings_schema: Some(json!({
|
|
|
|
|
"type": "object",
|
|
|
|
|
"properties": {
|
|
|
|
|
"require_reason_min_length": {
|
|
|
|
|
"type": "integer",
|
|
|
|
|
"title": "Minimum reason length",
|
|
|
|
|
"description": "Minimum characters required for moderation justifications",
|
|
|
|
|
"default": 20,
|
|
|
|
|
"minimum": 10,
|
|
|
|
|
"maximum": 500
|
|
|
|
|
},
|
|
|
|
|
"require_rule_reference": {
|
|
|
|
|
"type": "boolean",
|
|
|
|
|
"title": "Require rule reference",
|
|
|
|
|
"description": "Require moderators to cite a specific community rule",
|
|
|
|
|
"default": false
|
|
|
|
|
},
|
|
|
|
|
"public_ledger": {
|
|
|
|
|
"type": "boolean",
|
|
|
|
|
"title": "Public ledger",
|
|
|
|
|
"description": "Allow all community members to view the moderation ledger",
|
|
|
|
|
"default": true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn register(&self, system: &mut PluginSystem) {
|
|
|
|
|
let plugin_name = self.metadata().name.to_string();
|
|
|
|
|
|
|
|
|
|
// Hook: Log content removal
|
|
|
|
|
system.add_action(
|
|
|
|
|
"moderation.content_removed",
|
|
|
|
|
plugin_name.clone(),
|
|
|
|
|
1, // Highest priority - must log before anything else
|
|
|
|
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
|
|
|
|
let plugin_name = plugin_name.clone();
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let actor_id = ctx.actor_user_id
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let target_id = payload
|
|
|
|
|
.get("content_id")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.and_then(|s| Uuid::parse_str(s).ok())
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing content_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let content_type = payload
|
|
|
|
|
.get("content_type")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("unknown");
|
|
|
|
|
|
|
|
|
|
let reason = payload
|
|
|
|
|
.get("reason")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("No reason provided");
|
|
|
|
|
|
|
|
|
|
let actor_role = payload
|
|
|
|
|
.get("actor_role")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("moderator");
|
|
|
|
|
|
|
|
|
|
let entry_id = LedgerService::create_entry(
|
|
|
|
|
&ctx.pool,
|
|
|
|
|
ctx.community_id,
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
ModerationActionType::ContentRemove,
|
|
|
|
|
content_type,
|
|
|
|
|
target_id,
|
|
|
|
|
reason,
|
|
|
|
|
payload.get("rule_reference").and_then(|v| v.as_str()),
|
|
|
|
|
payload.get("evidence").cloned(),
|
|
|
|
|
payload.get("content_snapshot").cloned(),
|
|
|
|
|
None,
|
|
|
|
|
"unilateral",
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
).await?;
|
|
|
|
|
|
|
|
|
|
let _ = ctx.emit_public_event(
|
|
|
|
|
Some(&plugin_name),
|
|
|
|
|
"ledger.entry_created",
|
|
|
|
|
json!({
|
|
|
|
|
"entry_id": entry_id,
|
|
|
|
|
"action_type": "content_remove",
|
|
|
|
|
"target_type": content_type,
|
|
|
|
|
}),
|
|
|
|
|
).await;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Hook: Log user moderation
|
|
|
|
|
let plugin_name2 = self.metadata().name.to_string();
|
|
|
|
|
system.add_action(
|
|
|
|
|
"moderation.user_action",
|
|
|
|
|
plugin_name2.clone(),
|
|
|
|
|
1,
|
|
|
|
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
|
|
|
|
let plugin_name = plugin_name2.clone();
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let actor_id = ctx.actor_user_id
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let target_user_id = payload
|
|
|
|
|
.get("target_user_id")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.and_then(|s| Uuid::parse_str(s).ok())
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing target_user_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let action = payload
|
|
|
|
|
.get("action")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("user_warn");
|
|
|
|
|
|
|
|
|
|
let action_type = match action {
|
|
|
|
|
"warn" => ModerationActionType::UserWarn,
|
|
|
|
|
"mute" => ModerationActionType::UserMute,
|
|
|
|
|
"unmute" => ModerationActionType::UserUnmute,
|
|
|
|
|
"suspend" => ModerationActionType::UserSuspend,
|
|
|
|
|
"unsuspend" => ModerationActionType::UserUnsuspend,
|
|
|
|
|
"ban" => ModerationActionType::UserBan,
|
|
|
|
|
"unban" => ModerationActionType::UserUnban,
|
|
|
|
|
_ => ModerationActionType::UserWarn,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let reason = payload
|
|
|
|
|
.get("reason")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("No reason provided");
|
|
|
|
|
|
|
|
|
|
let actor_role = payload
|
|
|
|
|
.get("actor_role")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("moderator");
|
|
|
|
|
|
|
|
|
|
let duration = payload
|
|
|
|
|
.get("duration_hours")
|
|
|
|
|
.and_then(|v| v.as_i64())
|
|
|
|
|
.map(|d| d as i32);
|
|
|
|
|
|
|
|
|
|
let entry_id = LedgerService::create_entry(
|
|
|
|
|
&ctx.pool,
|
|
|
|
|
ctx.community_id,
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
action_type,
|
|
|
|
|
"user",
|
|
|
|
|
target_user_id,
|
|
|
|
|
reason,
|
|
|
|
|
payload.get("rule_reference").and_then(|v| v.as_str()),
|
|
|
|
|
payload.get("evidence").cloned(),
|
|
|
|
|
None,
|
|
|
|
|
duration,
|
|
|
|
|
"unilateral",
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
).await?;
|
|
|
|
|
|
|
|
|
|
let _ = ctx.emit_public_event(
|
|
|
|
|
Some(&plugin_name),
|
|
|
|
|
"ledger.entry_created",
|
|
|
|
|
json!({
|
|
|
|
|
"entry_id": entry_id,
|
|
|
|
|
"action_type": action,
|
|
|
|
|
"target_type": "user",
|
|
|
|
|
}),
|
|
|
|
|
).await;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Hook: Log proposal moderation
|
|
|
|
|
let plugin_name3 = self.metadata().name.to_string();
|
|
|
|
|
system.add_action(
|
|
|
|
|
"moderation.proposal_action",
|
|
|
|
|
plugin_name3.clone(),
|
|
|
|
|
1,
|
|
|
|
|
Arc::new(move |ctx: HookContext, payload: Value| {
|
|
|
|
|
let plugin_name = plugin_name3.clone();
|
|
|
|
|
Box::pin(async move {
|
|
|
|
|
let actor_id = ctx.actor_user_id
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing actor_user_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let proposal_id = payload
|
|
|
|
|
.get("proposal_id")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.and_then(|s| Uuid::parse_str(s).ok())
|
|
|
|
|
.ok_or_else(|| PluginError::Message("Missing proposal_id".into()))?;
|
|
|
|
|
|
|
|
|
|
let action = payload
|
|
|
|
|
.get("action")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("close");
|
|
|
|
|
|
|
|
|
|
let action_type = match action {
|
|
|
|
|
"close" => ModerationActionType::ProposalClose,
|
|
|
|
|
"reopen" => ModerationActionType::ProposalReopen,
|
|
|
|
|
"archive" => ModerationActionType::ProposalArchive,
|
|
|
|
|
_ => ModerationActionType::ProposalClose,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let reason = payload
|
|
|
|
|
.get("reason")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("No reason provided");
|
|
|
|
|
|
|
|
|
|
let actor_role = payload
|
|
|
|
|
.get("actor_role")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("moderator");
|
|
|
|
|
|
|
|
|
|
let decision_type = payload
|
|
|
|
|
.get("decision_type")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.unwrap_or("unilateral");
|
|
|
|
|
|
|
|
|
|
let vote_proposal_id = payload
|
|
|
|
|
.get("vote_proposal_id")
|
|
|
|
|
.and_then(|v| v.as_str())
|
|
|
|
|
.and_then(|s| Uuid::parse_str(s).ok());
|
|
|
|
|
|
|
|
|
|
let entry_id = LedgerService::create_entry(
|
|
|
|
|
&ctx.pool,
|
|
|
|
|
ctx.community_id,
|
|
|
|
|
actor_id,
|
|
|
|
|
actor_role,
|
|
|
|
|
action_type,
|
|
|
|
|
"proposal",
|
|
|
|
|
proposal_id,
|
|
|
|
|
reason,
|
|
|
|
|
payload.get("rule_reference").and_then(|v| v.as_str()),
|
|
|
|
|
payload.get("evidence").cloned(),
|
|
|
|
|
payload.get("proposal_snapshot").cloned(),
|
|
|
|
|
None,
|
|
|
|
|
decision_type,
|
|
|
|
|
vote_proposal_id,
|
|
|
|
|
payload.get("vote_result").cloned(),
|
|
|
|
|
).await?;
|
|
|
|
|
|
|
|
|
|
let _ = ctx.emit_public_event(
|
|
|
|
|
Some(&plugin_name),
|
|
|
|
|
"ledger.entry_created",
|
|
|
|
|
json!({
|
|
|
|
|
"entry_id": entry_id,
|
|
|
|
|
"action_type": action,
|
|
|
|
|
"target_type": "proposal",
|
|
|
|
|
}),
|
|
|
|
|
).await;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|