diff --git a/divine/plugins/src/divine_register_plugins.py b/divine/plugins/src/divine_register_plugins.py index a68a127c..df9d065b 100644 --- a/divine/plugins/src/divine_register_plugins.py +++ b/divine/plugins/src/divine_register_plugins.py @@ -8,6 +8,7 @@ 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 @@ -15,7 +16,7 @@ @hookimpl_osprey def register_udfs() -> Sequence[Type[UDFBase[Any, Any]]]: - return [BanNostrEvent, NostrAccountAge, CheckModerationResult] + return [AgeRestrictNostrEvent, BanNostrEvent, NostrAccountAge, CheckModerationResult] @hookimpl_osprey diff --git a/divine/plugins/src/services/relay_manager_sink.py b/divine/plugins/src/services/relay_manager_sink.py index 20865ad9..0c3d6c17 100644 --- a/divine/plugins/src/services/relay_manager_sink.py +++ b/divine/plugins/src/services/relay_manager_sink.py @@ -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 @@ -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: @@ -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' ) @@ -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', @@ -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], } @@ -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 diff --git a/divine/plugins/src/udfs/age_restrict_nostr_event.py b/divine/plugins/src/udfs/age_restrict_nostr_event.py new file mode 100644 index 00000000..1c9578cd --- /dev/null +++ b/divine/plugins/src/udfs/age_restrict_nostr_event.py @@ -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) diff --git a/divine/rules/rules/content/label_routing.sml b/divine/rules/rules/content/label_routing.sml index e52d6388..65bf601f 100644 --- a/divine/rules/rules/content/label_routing.sml +++ b/divine/rules/rules/content/label_routing.sml @@ -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'), @@ -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'), @@ -94,8 +94,6 @@ ConfirmedCSAM = Rule( LabelValue in ['csam', 'sexual_minors'], LabelSource == 'human-moderator', not LabelRejected, - LabelTargetEvent != None, - LabelTargetEvent != '', ], description='Human confirmed CSAM', ) @@ -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', ) @@ -189,8 +185,6 @@ RejectedLabel = Rule( LabelNamespace == 'content-warning', LabelSource == 'human-moderator', LabelRejected, - LabelTargetEvent != None, - LabelTargetEvent != '', ], description='Human rejected AI classification (false positive)', )