Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion divine/plugins/src/divine_register_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@
from services.labels_service import PostgresLabelsService
from services.relay_manager_sink import RelayManagerSink
from services.zendesk_sink import ZendeskSink
from udfs.age_restrict_nostr_event import AgeRestrictNostrEvent
from udfs.ban_nostr_event import BanNostrEvent
from udfs.check_moderation_result import CheckModerationResult
from udfs.nostr_account_age import NostrAccountAge


@hookimpl_osprey
def register_udfs() -> Sequence[Type[UDFBase[Any, Any]]]:
return [BanNostrEvent, NostrAccountAge, CheckModerationResult]
return [AgeRestrictNostrEvent, BanNostrEvent, NostrAccountAge, CheckModerationResult]


@hookimpl_osprey
Expand Down
51 changes: 34 additions & 17 deletions divine/plugins/src/services/relay_manager_sink.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,17 @@
from osprey.engine.executor.execution_context import ExecutionResult
from osprey.worker.lib.osprey_shared.logging import get_logger
from osprey.worker.sinks.sink.output_sink import BaseOutputSink
from udfs.age_restrict_nostr_event import AgeRestrictEffect
from udfs.ban_nostr_event import BanEventEffect

logger = get_logger(__name__)


class RelayManagerSink(BaseOutputSink):
"""Output sink that sends ban actions to Divine's relay-manager NIP-86 endpoint.
"""Output sink that sends moderation actions to Divine's relay-manager.

Supports both ``banevent`` (content removal) and ``banpubkey`` (user ban)
via the ``/api/relay-rpc`` JSON-RPC endpoint.

Configuration (environment variables):
- ``DIVINE_RELAY_MANAGER_URL``: Required. Base URL of the relay-manager
worker (e.g. ``https://api-relay-prod.divine.video``).
- ``DIVINE_RELAY_MANAGER_API_KEY``: Required. Value for the ``X-Admin-Key``
header. Must match the ``ADMIN_API_KEY`` secret on the target worker.
Supports ``banevent``/``banpubkey`` via ``/api/relay-rpc`` and
``AGE_RESTRICTED`` via ``/api/moderate-media``.
"""

timeout: float = 5.0
Expand All @@ -43,11 +38,13 @@ def _headers(self) -> Dict[str, str]:
def will_do_work(self, result: ExecutionResult) -> bool:
if not self._url:
return False
return len(result.effects.get(BanEventEffect, [])) > 0
has_bans = len(result.effects.get(BanEventEffect, [])) > 0
has_restricts = len(result.effects.get(AgeRestrictEffect, [])) > 0
return has_bans or has_restricts

def push(self, result: ExecutionResult) -> None:
effects: List[BanEventEffect] = result.effects.get(BanEventEffect, [])
for effect in effects:
ban_effects: List[BanEventEffect] = result.effects.get(BanEventEffect, [])
for effect in ban_effects:
assert isinstance(effect, BanEventEffect)
event_banned = False
try:
Expand All @@ -68,10 +65,6 @@ def push(self, result: ExecutionResult) -> None:
except Exception:
# Re-raise so the sink retry path re-attempts the whole push.
# Ban and pubkey-ban are idempotent, so replaying them is safe.
# Label publish is NOT idempotent (each attempt creates a new
# signed event), so retries may produce duplicate enforcement
# labels. Acceptable: duplicates are cosmetic, and losing the
# audit trail is worse than duplicating it.
logger.error(
f'Failed to publish enforcement label for {effect.event_id} — ban succeeded, label lost'
)
Expand All @@ -80,6 +73,11 @@ def push(self, result: ExecutionResult) -> None:
if not event_banned:
raise RuntimeError(f'Failed to ban event {effect.event_id}')

restrict_effects: List[AgeRestrictEffect] = result.effects.get(AgeRestrictEffect, [])
for effect in restrict_effects:
assert isinstance(effect, AgeRestrictEffect)
self._age_restrict_media(effect)

def _ban_event(self, effect: BanEventEffect) -> None:
payload: Dict[str, Any] = {
'method': 'banevent',
Expand All @@ -99,7 +97,7 @@ def _ban_event(self, effect: BanEventEffect) -> None:
raise

def _ban_pubkey(self, effect: BanEventEffect) -> None:
payload: Dict[str, Any] = {
payload: Dict[str, str] = {
'method': 'banpubkey',
'params': [effect.pubkey, effect.reason],
}
Expand Down Expand Up @@ -148,5 +146,24 @@ def _publish_label_event(self, effect: BanEventEffect) -> None:
logger.exception(f'Failed to publish enforcement label for event {effect.event_id}')
raise

def _age_restrict_media(self, effect: AgeRestrictEffect) -> None:
payload: Dict[str, Any] = {
'sha256': effect.sha256,
'action': 'AGE_RESTRICTED',
'reason': effect.reason,
}
try:
resp = requests.post(
f'{self._url}/api/moderate-media',
json=payload,
headers=self._headers(),
timeout=self.timeout,
)
resp.raise_for_status()
logger.info(f'Age-restricted media {effect.sha256} for event {effect.event_id}')
except Exception:
logger.exception(f'Failed to age-restrict media {effect.sha256} for event {effect.event_id}')
raise

def stop(self) -> None:
pass
60 changes: 60 additions & 0 deletions divine/plugins/src/udfs/age_restrict_nostr_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from dataclasses import dataclass
from typing import List, Self, cast

from osprey.engine.executor.custom_extracted_features import CustomExtractedFeature
from osprey.engine.executor.execution_context import ExecutionContext
from osprey.engine.language_types.effects import EffectBase, EffectToCustomExtractedFeatureBase
from osprey.engine.stdlib.udfs.categories import UdfCategories
from osprey.engine.udf.arguments import ArgumentsBase
from osprey.engine.udf.base import UDFBase
from osprey.engine.utils.types import add_slots


class AgeRestrictNostrEventArguments(ArgumentsBase):
event_id: str
sha256: str
reason: str


@dataclass
class AgeRestrictEffect(EffectToCustomExtractedFeatureBase[List[str]]):
"""Effect requesting age-restriction of media via relay-manager's moderate-media endpoint."""

event_id: str
sha256: str
reason: str

def to_str(self) -> str:
return f'{self.event_id}|{self.sha256}|{self.reason}'

@classmethod
def build_custom_extracted_feature_from_list(cls, values: List[Self]) -> CustomExtractedFeature[List[str]]:
return AgeRestrictEffectsExtractedFeature(effects=cast(List[AgeRestrictEffect], values))


@add_slots
@dataclass
class AgeRestrictEffectsExtractedFeature(CustomExtractedFeature[List[str]]):
effects: List[AgeRestrictEffect]

@classmethod
def feature_name(cls) -> str:
return 'age_restrict_nostr_event'

def get_serializable_feature(self) -> List[str] | None:
return [effect.to_str() for effect in self.effects]


def synthesize_effect(arguments: AgeRestrictNostrEventArguments) -> AgeRestrictEffect:
return AgeRestrictEffect(
event_id=arguments.event_id,
sha256=arguments.sha256,
reason=arguments.reason,
)


class AgeRestrictNostrEvent(UDFBase[AgeRestrictNostrEventArguments, EffectBase]):
category = UdfCategories.ENGINE

def execute(self, execution_context: ExecutionContext, arguments: AgeRestrictNostrEventArguments) -> EffectBase:
return synthesize_effect(arguments)
18 changes: 6 additions & 12 deletions divine/rules/rules/content/label_routing.sml
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ ConfirmedNudity = Rule(
LabelValue in ['nudity', 'sexual', 'explicit', 'pornography'],
LabelSource == 'human-moderator',
not LabelRejected,
LabelTargetEvent != None,
LabelTargetEvent != '',
LabelContentHash != '',
],
description='Human confirmed nudity/sexual content',
description='Human confirmed nudity/sexual content (with media hash)',
)

WhenRules(
rules_any=[ConfirmedNudity],
then=[
AgeRestrictNostrEvent(event_id=LabelTargetEvent, sha256=LabelContentHash, reason='Human confirmed nudity'),
LabelAdd(entity=LabelTargetEventEntity, label='age_restricted'),
LabelAdd(entity=LabelTargetEventEntity, label='human_reviewed'),
DeclareVerdict(verdict='restrict'),
Expand All @@ -70,15 +70,15 @@ ConfirmedViolence = Rule(
LabelValue in ['violence', 'gore', 'graphic-violence'],
LabelSource == 'human-moderator',
not LabelRejected,
LabelTargetEvent != None,
LabelTargetEvent != '',
LabelContentHash != '',
],
description='Human confirmed violence/gore content',
description='Human confirmed violence/gore content (with media hash)',
)

WhenRules(
rules_any=[ConfirmedViolence],
then=[
AgeRestrictNostrEvent(event_id=LabelTargetEvent, sha256=LabelContentHash, reason='Human confirmed violence'),
LabelAdd(entity=LabelTargetEventEntity, label='age_restricted'),
LabelAdd(entity=LabelTargetEventEntity, label='human_reviewed'),
DeclareVerdict(verdict='restrict'),
Expand All @@ -94,8 +94,6 @@ ConfirmedCSAM = Rule(
LabelValue in ['csam', 'sexual_minors'],
LabelSource == 'human-moderator',
not LabelRejected,
LabelTargetEvent != None,
LabelTargetEvent != '',
],
description='Human confirmed CSAM',
)
Expand Down Expand Up @@ -164,8 +162,6 @@ ConfirmedAIGenerated = Rule(
LabelValue in ['ai-generated', 'deepfake'],
LabelSource == 'human-moderator',
not LabelRejected,
LabelTargetEvent != None,
LabelTargetEvent != '',
],
description='Human confirmed AI-generated or deepfake content',
)
Expand All @@ -189,8 +185,6 @@ RejectedLabel = Rule(
LabelNamespace == 'content-warning',
LabelSource == 'human-moderator',
LabelRejected,
LabelTargetEvent != None,
LabelTargetEvent != '',
],
description='Human rejected AI classification (false positive)',
)
Expand Down
Loading