Split moderation heartbeat from Void rumination
This commit is contained in:
parent
ddb6148050
commit
a055cc286e
12 changed files with 843 additions and 75 deletions
375
scripts/enforce-discord-moderation-policy.mjs
Normal file
375
scripts/enforce-discord-moderation-policy.mjs
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { TextDecoder } from "node:util";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
const POLICY = {
|
||||
safety_threat: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
weaponized_intimidation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
stalking_or_doxxing: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
sexual_boundary_violation: { strikeExpiresDays: 365, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
bigotry_identity_attack: { strikeExpiresDays: 180, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
bad_faith_argument: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
nsfw_channel_violation: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
spam_or_deceptive_promotion: { strikeExpiresDays: 30, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
moderator_obstruction: { strikeExpiresDays: 90, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
empty_words_noise: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
values_debate_escalation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
pg13_language_violation: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
event_time_coordination: { strikeExpiresDays: 14, instantBanTags: ["severity:instaban"], threeStrike: true },
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const mode = normalizeMode(options.mode ?? readLocalEnv().VOID_MODERATION_ENFORCEMENT_MODE ?? "log_only");
|
||||
const operations = await readOperations(options.operationsFile);
|
||||
const newCases = operations
|
||||
.filter((operation) => operation?.operation === "upsert_open_case" && operation.case)
|
||||
.map((operation) => operation.case);
|
||||
|
||||
if (newCases.length === 0) {
|
||||
writeJson({ ok: true, mode, status: "no_new_cases", actions: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await loadTypedState(options.stateFile);
|
||||
const actions = [];
|
||||
for (const moderationCase of newCases) {
|
||||
const action = await evaluateCase({ moderationCase, state, mode, dryRun: options.dryRun });
|
||||
if (action) {
|
||||
actions.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
writeJson({
|
||||
ok: actions.every((action) => action.ok !== false),
|
||||
mode,
|
||||
status: actions.length > 0 ? "evaluated" : "no_policy_action",
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const options = {
|
||||
stateFile: undefined,
|
||||
operationsFile: undefined,
|
||||
mode: undefined,
|
||||
dryRun: false,
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index];
|
||||
switch (argument) {
|
||||
case "--state-file":
|
||||
options.stateFile = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
case "--operations-file":
|
||||
options.operationsFile = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
case "--mode":
|
||||
options.mode = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
case "--dry-run":
|
||||
options.dryRun = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
if (!options.stateFile) {
|
||||
throw new Error("Provide --state-file.");
|
||||
}
|
||||
if (!options.operationsFile) {
|
||||
throw new Error("Provide --operations-file.");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function evaluateCase({ moderationCase, state, mode, dryRun }) {
|
||||
const tags = new Set((moderationCase.tags ?? []).map((tag) => String(tag).toLowerCase()));
|
||||
const infringementType = getTagValue(tags, "infringement:");
|
||||
if (!infringementType || !POLICY[infringementType]) {
|
||||
return {
|
||||
ok: true,
|
||||
status: "skipped",
|
||||
reason: "missing_or_unknown_infringement_type",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
tags: [...tags],
|
||||
};
|
||||
}
|
||||
|
||||
if (!moderationCase.authorId) {
|
||||
return {
|
||||
ok: true,
|
||||
status: "skipped",
|
||||
reason: "missing_author_id",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
infringementType,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasActionedTag(tags)) {
|
||||
return {
|
||||
ok: true,
|
||||
status: "skipped",
|
||||
reason: "already_actioned",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
infringementType,
|
||||
};
|
||||
}
|
||||
|
||||
const policy = POLICY[infringementType];
|
||||
const isInstantBan = tags.has("moderation:instaban") ||
|
||||
policy.instantBanTags.some((tag) => tags.has(tag));
|
||||
const strikeCount = countActiveStrikes({
|
||||
cases: state.moderationCursor?.openCases ?? [],
|
||||
authorId: moderationCase.authorId,
|
||||
infringementType,
|
||||
expiresDays: policy.strikeExpiresDays,
|
||||
now: new Date(),
|
||||
});
|
||||
const isThirdStrike = tags.has("moderation:strike") && policy.threeStrike && strikeCount >= 3;
|
||||
|
||||
if (isInstantBan || isThirdStrike) {
|
||||
const reason = isInstantBan
|
||||
? `VoidBot instaban: ${infringementType} at ${moderationCase.sourceMessageId}`
|
||||
: `VoidBot three-strike ban: ${infringementType} strike ${strikeCount}/3 at ${moderationCase.sourceMessageId}`;
|
||||
const moderationResult = await invokeModerationAction({
|
||||
action: "ban",
|
||||
userId: moderationCase.authorId,
|
||||
reason,
|
||||
mode,
|
||||
dryRun,
|
||||
});
|
||||
await closeCase({
|
||||
stateFile: state.__stateFile,
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
status: "resolved",
|
||||
resolutionSummary: `${isInstantBan ? "Instaban" : "Three-strike ban"} applied for ${infringementType}.`,
|
||||
dryRun,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: isInstantBan ? "instaban_applied" : "three_strike_ban_applied",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
authorId: moderationCase.authorId,
|
||||
infringementType,
|
||||
strikeCount,
|
||||
strikeExpiresDays: policy.strikeExpiresDays,
|
||||
moderationResult,
|
||||
};
|
||||
}
|
||||
|
||||
if (tags.has("moderation:strike")) {
|
||||
await closeCase({
|
||||
stateFile: state.__stateFile,
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
status: "resolved",
|
||||
resolutionSummary: `Strike ${strikeCount}/3 recorded for ${infringementType}; expires after ${policy.strikeExpiresDays} days without another matching strike.`,
|
||||
dryRun,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
status: "strike_recorded",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
authorId: moderationCase.authorId,
|
||||
infringementType,
|
||||
strikeCount,
|
||||
strikeExpiresDays: policy.strikeExpiresDays,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: "case_recorded_no_sanction",
|
||||
sourceMessageId: moderationCase.sourceMessageId,
|
||||
authorId: moderationCase.authorId,
|
||||
infringementType,
|
||||
strikeCount,
|
||||
strikeExpiresDays: policy.strikeExpiresDays,
|
||||
};
|
||||
}
|
||||
|
||||
function countActiveStrikes({ cases, authorId, infringementType, expiresDays, now }) {
|
||||
const cutoffMs = now.getTime() - expiresDays * 24 * 60 * 60 * 1000;
|
||||
return cases.filter((entry) => {
|
||||
if (entry.authorId !== authorId) {
|
||||
return false;
|
||||
}
|
||||
const tags = new Set((entry.tags ?? []).map((tag) => String(tag).toLowerCase()));
|
||||
if (!tags.has(`infringement:${infringementType}`) || !tags.has("moderation:strike")) {
|
||||
return false;
|
||||
}
|
||||
const timestampMs = Date.parse(entry.createdAt ?? entry.lastTouchedAt ?? "");
|
||||
return Number.isFinite(timestampMs) && timestampMs >= cutoffMs;
|
||||
}).length;
|
||||
}
|
||||
|
||||
async function invokeModerationAction({ action, userId, reason, mode, dryRun }) {
|
||||
if (["off", "disabled", "log_only", "log-only", "case_only", "case-only"].includes(mode)) {
|
||||
return { ok: true, status: "skipped", reason: "enforcement_mode_non_destructive", action, userId };
|
||||
}
|
||||
if (mode === "notify_owner") {
|
||||
return { ok: true, status: "skipped", reason: "notify_owner_does_not_apply_discord_sanctions", action, userId };
|
||||
}
|
||||
if (!["policy", "enforce_policy", "enforce-policy", "ban"].includes(mode)) {
|
||||
return { ok: true, status: "skipped", reason: `mode_${mode}_does_not_apply_policy_bans`, action, userId };
|
||||
}
|
||||
|
||||
const args = [
|
||||
resolve(repoRoot, "scripts", "moderate-discord-user.mjs"),
|
||||
"--action", action,
|
||||
"--user-id", userId,
|
||||
"--reason", reason,
|
||||
];
|
||||
if (dryRun) {
|
||||
args.push("--dry-run");
|
||||
}
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Discord moderation action failed: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
|
||||
async function closeCase({ stateFile, sourceMessageId, status, resolutionSummary, dryRun }) {
|
||||
if (dryRun) {
|
||||
return;
|
||||
}
|
||||
const tempRoot = mkdtempSync(resolve(tmpdir(), "void-policy-close-"));
|
||||
const operationPath = resolve(tempRoot, "operation.json");
|
||||
try {
|
||||
writeFileSync(operationPath, `${JSON.stringify({
|
||||
operation: "close_open_case",
|
||||
sourceMessageId,
|
||||
status,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolutionSummary,
|
||||
}, null, 2)}\n`, "utf8");
|
||||
const result = spawnSync(process.execPath, [
|
||||
resolve(repoRoot, "scripts", "void-self-state.mjs"),
|
||||
"apply-operation",
|
||||
"--canonical", stateFile,
|
||||
"--operation-file", operationPath,
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to close moderation case: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMode(value) {
|
||||
return String(value).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getTagValue(tags, prefix) {
|
||||
for (const tag of tags) {
|
||||
if (tag.startsWith(prefix)) {
|
||||
return tag.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasActionedTag(tags) {
|
||||
for (const tag of tags) {
|
||||
if (tag.startsWith("moderation:actioned")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readOperations(path) {
|
||||
const raw = await readFile(path, "utf8");
|
||||
if (!raw.trim()) {
|
||||
return [];
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
async function loadTypedState(stateFile) {
|
||||
const script = `
|
||||
const core = require('./packages/core/dist');
|
||||
core.loadVoidSelfStateTypedDocuments({ canonicalPath: process.argv[1] })
|
||||
.then((state) => console.log(JSON.stringify(state)))
|
||||
.catch((error) => { console.error(error); process.exit(1); });
|
||||
`;
|
||||
const result = spawnSync(process.execPath, ["-e", script, stateFile], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to load typed state: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
return { ...JSON.parse(result.stdout), __stateFile: stateFile };
|
||||
}
|
||||
|
||||
function readLocalEnv() {
|
||||
const envPath = resolve(repoRoot, ".env");
|
||||
const parsed = { ...process.env };
|
||||
try {
|
||||
const raw = stripLeadingBom(readTextFileFlexible(envPath));
|
||||
Object.assign(parsed, parseDotEnv(raw));
|
||||
} catch (error) {
|
||||
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseDotEnv(raw) {
|
||||
const result = {};
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const separatorIndex = trimmed.indexOf("=");
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
result[trimmed.slice(0, separatorIndex).trim()] = trimmed.slice(separatorIndex + 1).trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function readTextFileFlexible(path) {
|
||||
const buffer = readFileSync(path);
|
||||
if (buffer.length >= 2) {
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||||
return new TextDecoder("utf-16le").decode(buffer);
|
||||
}
|
||||
if (buffer[0] === 0xfe && buffer[1] === 0xff) {
|
||||
return new TextDecoder("utf-16be").decode(buffer);
|
||||
}
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(buffer);
|
||||
}
|
||||
|
||||
function stripLeadingBom(value) {
|
||||
return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value;
|
||||
}
|
||||
|
||||
function writeJson(value) {
|
||||
process.stdout.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
|
@ -311,6 +311,15 @@ try {
|
|||
Executable = $powershell
|
||||
Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-mood-drift.ps1"))
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = "void-moderation-heartbeat"
|
||||
Label = "Void rules moderation heartbeat"
|
||||
IntervalMinutes = Get-ConfigInt -Config $config -Name "VOIDBOT_MODERATION_HEARTBEAT_INTERVAL_MINUTES" -Default 1 -Minimum 1
|
||||
TimeoutMinutes = 10
|
||||
Cwd = $repoRoot
|
||||
Executable = $powershell
|
||||
Arguments = @("-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $PSScriptRoot "run-void-moderator-rumination.ps1"), "-ModerationHeartbeatOnly")
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Id = "void-moderation-rumination"
|
||||
Label = "Void moderation rumination"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
param(
|
||||
[string] $StateFilePath,
|
||||
[switch] $NoPost,
|
||||
[switch] $SkipModel
|
||||
[switch] $SkipModel,
|
||||
[switch] $ModerationHeartbeatOnly
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
|
@ -26,24 +27,30 @@ $logDir = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_LOG_DIR)) {
|
|||
} else {
|
||||
Join-Path $repoRoot ".voidbot\logs"
|
||||
}
|
||||
$statusPath = Join-Path $statusDir "moderation-rumination.json"
|
||||
$summaryLogPath = Join-Path $logDir "moderation-rumination.log"
|
||||
$tracePath = Join-Path $logDir "moderation-rumination-last.jsonl"
|
||||
$lastMessagePath = Join-Path $statusDir "moderation-rumination-last-message.txt"
|
||||
$operationOutputPath = Join-Path $statusDir "moderation-rumination-operations.json"
|
||||
$contextPath = Join-Path $statusDir "moderation-rumination-context.json"
|
||||
$lockPath = Join-Path $statusDir "moderation-rumination.lock"
|
||||
$runSlug = if ($ModerationHeartbeatOnly) { "moderation-heartbeat" } else { "moderation-rumination" }
|
||||
$statusPath = Join-Path $statusDir "$runSlug.json"
|
||||
$summaryLogPath = Join-Path $logDir "$runSlug.log"
|
||||
$tracePath = Join-Path $logDir "$runSlug-last.jsonl"
|
||||
$lastMessagePath = Join-Path $statusDir "$runSlug-last-message.txt"
|
||||
$operationOutputPath = Join-Path $statusDir "$runSlug-operations.json"
|
||||
$contextPath = Join-Path $statusDir "$runSlug-context.json"
|
||||
$lockPath = Join-Path $statusDir "$runSlug.lock"
|
||||
$pendingMentionsPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_PENDING_MENTIONS_PATH)) {
|
||||
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_RUMINATION_PENDING_MENTIONS_PATH)
|
||||
} else {
|
||||
Join-Path $statusDir "void-moderation-pending-mentions.json"
|
||||
}
|
||||
$promptTemplatePath = Join-Path $repoRoot "prompts\void-moderator-rumination.md"
|
||||
$promptTemplatePath = if ($ModerationHeartbeatOnly) {
|
||||
Join-Path $repoRoot "prompts\void-moderation-heartbeat.md"
|
||||
} else {
|
||||
Join-Path $repoRoot "prompts\void-moderator-rumination.md"
|
||||
}
|
||||
$contextProjectionScriptPath = Join-Path $repoRoot "scripts\lib\void-rumination-context-projection.ps1"
|
||||
$recentHistoryScriptPath = Join-Path $repoRoot "scripts\export-recent-discord-history.mjs"
|
||||
$repoActivityScriptPath = Join-Path $repoRoot "scripts\export-recent-repo-activity.mjs"
|
||||
$selfStateScriptPath = Join-Path $repoRoot "scripts\void-self-state.mjs"
|
||||
$moderationActionScriptPath = Join-Path $repoRoot "scripts\moderate-discord-user.mjs"
|
||||
$moderationPolicyScriptPath = Join-Path $repoRoot "scripts\enforce-discord-moderation-policy.mjs"
|
||||
$sendMessageScriptPath = if (-not [string]::IsNullOrWhiteSpace($env:VOID_SEND_DISCORD_SCRIPT)) {
|
||||
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($env:VOID_SEND_DISCORD_SCRIPT)
|
||||
} else {
|
||||
|
|
@ -523,11 +530,11 @@ function Invoke-UrgentModerationEnforcement {
|
|||
}
|
||||
|
||||
$normalizedMode = if ([string]::IsNullOrWhiteSpace($Mode)) { "log_only" } else { $Mode.Trim().ToLowerInvariant() }
|
||||
if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only")) {
|
||||
if ($normalizedMode -in @("off", "disabled", "log_only", "log-only", "case_only", "case-only", "policy", "enforce_policy", "enforce-policy", "ban")) {
|
||||
return @{
|
||||
mode = $normalizedMode
|
||||
status = "skipped"
|
||||
reason = "enforcement_mode_non_destructive"
|
||||
reason = if ($normalizedMode -in @("policy", "enforce_policy", "enforce-policy", "ban")) { "policy_enforcement_owned_by_moderation_heartbeat" } else { "enforcement_mode_non_destructive" }
|
||||
witnessCount = [int]$Witnesses.Count
|
||||
}
|
||||
}
|
||||
|
|
@ -582,7 +589,70 @@ function Invoke-UrgentModerationEnforcement {
|
|||
}
|
||||
}
|
||||
|
||||
throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, or timeout."
|
||||
throw "Unsupported VOID_MODERATION_ENFORCEMENT_MODE '$Mode'. Use log_only, notify_owner, timeout, or policy."
|
||||
}
|
||||
|
||||
function Assert-ModerationHeartbeatOperations {
|
||||
param(
|
||||
[object[]] $Operations,
|
||||
$TypedState
|
||||
)
|
||||
|
||||
foreach ($operation in $Operations) {
|
||||
$operationName = Get-ObjectPropertyString -Value $operation -Name "operation"
|
||||
if ($null -eq $operationName) {
|
||||
continue
|
||||
}
|
||||
if ($operationName -notin @("upsert_open_case", "close_open_case")) {
|
||||
throw "Moderation heartbeat may only emit upsert_open_case or close_open_case operations; got '$operationName'."
|
||||
}
|
||||
if ($operationName -eq "upsert_open_case") {
|
||||
$case = Get-ObjectPropertyValue -Value $operation -Name "case"
|
||||
$sourceMessageId = Get-ObjectPropertyString -Value $case -Name "sourceMessageId"
|
||||
$priorCase = @(
|
||||
@(Convert-ToValueArray -Value $TypedState.moderationCursor.openCases) |
|
||||
Where-Object { (Get-ObjectPropertyString -Value $_ -Name "sourceMessageId") -eq $sourceMessageId }
|
||||
) | Select-Object -First 1
|
||||
if ($null -ne $priorCase) {
|
||||
$priorStatus = Get-ObjectPropertyString -Value $priorCase -Name "status"
|
||||
$priorResolution = Get-ObjectPropertyString -Value $priorCase -Name "resolutionSummary"
|
||||
if ($priorStatus -in @("answered", "resolved", "closed", "retired", "dropped") -and $priorResolution -match "(?i)(instaban|three-strike ban|strike \d/3 recorded|ban applied)") {
|
||||
throw "Moderation heartbeat cannot reopen already-actioned case '$sourceMessageId'."
|
||||
}
|
||||
}
|
||||
$tags = @(Convert-ToValueArray -Value (Get-ObjectPropertyValue -Value $case -Name "tags")) | ForEach-Object { ([string]$_).ToLowerInvariant() }
|
||||
$infringementTags = @($tags | Where-Object { $_.StartsWith("infringement:") })
|
||||
$moderationTags = @($tags | Where-Object { $_ -in @("moderation:instaban", "moderation:strike", "moderation:case_only") })
|
||||
if ($infringementTags.Count -ne 1) {
|
||||
throw "Moderation heartbeat open cases must include exactly one infringement:<type> tag."
|
||||
}
|
||||
if ($moderationTags.Count -ne 1) {
|
||||
throw "Moderation heartbeat open cases must include exactly one moderation:instaban, moderation:strike, or moderation:case_only tag."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-ModerationPolicyEnforcement {
|
||||
param(
|
||||
[string] $Mode,
|
||||
[switch] $DryRun
|
||||
)
|
||||
|
||||
$arguments = @(
|
||||
$moderationPolicyScriptPath,
|
||||
"--state-file", $stateFilePath,
|
||||
"--operations-file", $operationOutputPath,
|
||||
"--mode", $Mode
|
||||
)
|
||||
if ($DryRun) {
|
||||
$arguments += "--dry-run"
|
||||
}
|
||||
$output = & node @arguments 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Moderation policy enforcement failed: $($output | Out-String)"
|
||||
}
|
||||
return $output | ConvertFrom-Json
|
||||
}
|
||||
|
||||
function Get-TypedSelfState {
|
||||
|
|
@ -1206,7 +1276,7 @@ if (Test-Path $operationOutputPath) {
|
|||
Remove-Item -LiteralPath $operationOutputPath -Force
|
||||
}
|
||||
|
||||
foreach ($requiredPath in @($contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $sendMessageScriptPath)) {
|
||||
foreach ($requiredPath in @($promptTemplatePath, $contextProjectionScriptPath, $recentHistoryScriptPath, $repoActivityScriptPath, $selfStateScriptPath, $moderationActionScriptPath, $moderationPolicyScriptPath, $sendMessageScriptPath)) {
|
||||
if (-not (Test-Path $requiredPath)) {
|
||||
throw "Missing required helper at $requiredPath"
|
||||
}
|
||||
|
|
@ -1262,6 +1332,7 @@ $moderationTimeoutMinutes = Get-ConfigValue -Name "VOID_MODERATION_TIMEOUT_MINUT
|
|||
if ([string]::IsNullOrWhiteSpace($moderationTimeoutMinutes)) {
|
||||
$moderationTimeoutMinutes = "60"
|
||||
}
|
||||
$moderationPolicyDryRun = -not [string]::IsNullOrWhiteSpace($env:VOID_MODERATION_POLICY_DRY_RUN)
|
||||
|
||||
$typedContext = Get-TypedSelfState
|
||||
$typedState = $typedContext.typedState
|
||||
|
|
@ -1295,54 +1366,79 @@ if ($messageCount -gt 0) {
|
|||
}
|
||||
|
||||
$repoActivity = $null
|
||||
try {
|
||||
$repoActivity = Invoke-NodeJson -Arguments @(
|
||||
$repoActivityScriptPath,
|
||||
"--hours", "96",
|
||||
"--max-commits", "3",
|
||||
"--read-only",
|
||||
"--state-path", $stateFilePath
|
||||
)
|
||||
} catch {
|
||||
if ($ModerationHeartbeatOnly) {
|
||||
$repoActivity = @{
|
||||
status = "failed"
|
||||
error = $_.Exception.Message
|
||||
status = "skipped"
|
||||
reason = "moderation_heartbeat_only"
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
$repoActivity = Invoke-NodeJson -Arguments @(
|
||||
$repoActivityScriptPath,
|
||||
"--hours", "96",
|
||||
"--max-commits", "3",
|
||||
"--read-only",
|
||||
"--state-path", $stateFilePath
|
||||
)
|
||||
} catch {
|
||||
$repoActivity = @{
|
||||
status = "failed"
|
||||
error = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pendingMentions = @()
|
||||
$pendingMentionPacket = Read-JsonFile -Path $pendingMentionsPath
|
||||
if ($null -ne $pendingMentionPacket) {
|
||||
$pendingMentionsValue = Get-ObjectPropertyValue -Value $pendingMentionPacket -Name "pendingMentions"
|
||||
if ($null -ne $pendingMentionsValue) {
|
||||
$pendingMentions = @(Convert-ToValueArray -Value $pendingMentionsValue)
|
||||
if (-not $ModerationHeartbeatOnly) {
|
||||
$pendingMentionPacket = Read-JsonFile -Path $pendingMentionsPath
|
||||
if ($null -ne $pendingMentionPacket) {
|
||||
$pendingMentionsValue = Get-ObjectPropertyValue -Value $pendingMentionPacket -Name "pendingMentions"
|
||||
if ($null -ne $pendingMentionsValue) {
|
||||
$pendingMentions = @(Convert-ToValueArray -Value $pendingMentionsValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-JsonFile -Path $contextPath -Data @{
|
||||
generated = "now"
|
||||
stateFile = $stateFilePath
|
||||
noPost = [bool]$NoPost
|
||||
selfStateSummary = $typedContext.summary
|
||||
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
|
||||
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
|
||||
speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc)
|
||||
memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc)
|
||||
shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc)
|
||||
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
|
||||
agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc)
|
||||
speechPressureObligations = $speechPressureObligations
|
||||
urgentModerationWitnesses = $urgentModerationWitnesses
|
||||
pendingMentions = $pendingMentions
|
||||
recentConversationTarget = Project-RecentConversationTargetForRumination -History $history -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
|
||||
publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
|
||||
deliverableCandidateCount = [int]$deliverableCandidates.Count
|
||||
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
|
||||
scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc
|
||||
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
|
||||
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
|
||||
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
|
||||
repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc
|
||||
if ($ModerationHeartbeatOnly) {
|
||||
Write-JsonFile -Path $contextPath -Data @{
|
||||
generated = "now"
|
||||
mode = "moderation_heartbeat"
|
||||
stateFile = $stateFilePath
|
||||
noPost = [bool]$NoPost
|
||||
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
|
||||
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
|
||||
urgentModerationWitnesses = $urgentModerationWitnesses
|
||||
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
|
||||
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
|
||||
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
|
||||
enforcementMode = $moderationEnforcementMode
|
||||
}
|
||||
} else {
|
||||
Write-JsonFile -Path $contextPath -Data @{
|
||||
generated = "now"
|
||||
stateFile = $stateFilePath
|
||||
noPost = [bool]$NoPost
|
||||
selfStateSummary = $typedContext.summary
|
||||
chronology = "Times in this prompt-facing context are relative phrases. Exact timestamps stay parent-owned for typed state and cursor bookkeeping."
|
||||
openCases = @(Project-OpenCasesForRumination -Cases $typedState.moderationCursor.openCases -Now $startedAtUtc)
|
||||
speechReceipts = @(Project-SpeechReceiptsForRumination -Receipts $typedState.speechReceipts.recentReceipts -Now $startedAtUtc)
|
||||
memories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.memories -Now $startedAtUtc)
|
||||
shortTermMemories = @(Project-MemoriesForRumination -Memories $typedState.thoughtMemory.shortTerm -Now $startedAtUtc)
|
||||
incubation = @(Project-IncubationForRumination -Threads $typedState.thoughtMemory.incubation -Now $startedAtUtc)
|
||||
agencyPressure = @(Project-AgencyPressureForRumination -Pressures $typedState.agencyPressure.pressures -Now $startedAtUtc)
|
||||
speechPressureObligations = $speechPressureObligations
|
||||
urgentModerationWitnesses = $urgentModerationWitnesses
|
||||
pendingMentions = $pendingMentions
|
||||
recentConversationTarget = Project-RecentConversationTargetForRumination -History $history -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
|
||||
publicSpeechTarget = Project-PublicSpeechTargetForRumination -ChannelId $publicRoomChannelId -PersonaName $publicRoomPersonaName -PersonaAvatarUrl $publicRoomPersonaAvatarUrl
|
||||
deliverableCandidateCount = [int]$deliverableCandidates.Count
|
||||
candidateInterventions = @(Project-InterventionsForRumination -Interventions $typedState.candidateInterventions.interventions -Now $startedAtUtc)
|
||||
scheduledRuntime = Project-ScheduledRuntimeForRumination -Runtime $typedState.scheduledRuntime -Now $startedAtUtc
|
||||
priorCursor = Project-CursorForRumination -Cursor $priorCursor -Now $startedAtUtc
|
||||
observedCursor = Project-CursorForRumination -Cursor $observedCursor -Now $startedAtUtc
|
||||
recentHistory = Project-RecentHistoryForRumination -History $history -Now $startedAtUtc
|
||||
repoActivity = Select-RuminationRepoActivity -RepoActivity $repoActivity -Now $startedAtUtc
|
||||
}
|
||||
}
|
||||
|
||||
$isNapping = [bool](Get-ObjectPropertyValue -Value $typedState.scheduledRuntime.sleepCycle -Name "isNapping")
|
||||
|
|
@ -1350,7 +1446,7 @@ $openCaseCount = @(
|
|||
@(Convert-ToValueArray -Value $typedState.moderationCursor.openCases) |
|
||||
Where-Object { Test-OpenCaseRequiresRumination -Case $_ }
|
||||
).Count
|
||||
if ($isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) {
|
||||
if (-not $ModerationHeartbeatOnly -and $isNapping -and $messageCount -eq 0 -and $openCaseCount -eq 0 -and $deliverableCandidates.Count -eq 0) {
|
||||
Append-RunLog "napping: no new room messages or open cases; skipping awake rumination."
|
||||
Write-JsonFile -Path $operationOutputPath -Data @()
|
||||
$finishedAtUtc = [DateTime]::UtcNow
|
||||
|
|
@ -1446,7 +1542,11 @@ $appliedOperations = @()
|
|||
$moderationEnforcementResult = $null
|
||||
|
||||
Assert-UrgentModerationWitnessesHandled -Witnesses $urgentModerationWitnesses -Operations $proposedOperations -TypedState $typedState
|
||||
Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations
|
||||
if ($ModerationHeartbeatOnly) {
|
||||
Assert-ModerationHeartbeatOperations -Operations $proposedOperations -TypedState $typedState
|
||||
} else {
|
||||
Assert-SpeechPressureObligationsResolved -Obligations $speechPressureObligations -Operations $proposedOperations
|
||||
}
|
||||
|
||||
Append-RunLog ("applying proposed operations: {0}" -f @($proposedOperations).Count)
|
||||
foreach ($operation in $proposedOperations) {
|
||||
|
|
@ -1458,7 +1558,11 @@ foreach ($operation in $proposedOperations) {
|
|||
$appliedOperations += Apply-TypedOperation -Operation $operation
|
||||
}
|
||||
|
||||
if (-not $NoPost) {
|
||||
if ($ModerationHeartbeatOnly) {
|
||||
$moderationEnforcementResult = Invoke-ModerationPolicyEnforcement `
|
||||
-Mode $moderationEnforcementMode `
|
||||
-DryRun:([bool]$moderationPolicyDryRun)
|
||||
} elseif (-not $NoPost) {
|
||||
$moderationEnforcementResult = Invoke-UrgentModerationEnforcement `
|
||||
-Witnesses $urgentModerationWitnesses `
|
||||
-Mode $moderationEnforcementMode `
|
||||
|
|
@ -1482,7 +1586,7 @@ if (
|
|||
|
||||
$repoCursorOperations = @()
|
||||
$disableRepoCursorAdvance = -not [string]::IsNullOrWhiteSpace($env:VOID_RUMINATION_DISABLE_REPO_CURSOR_ADVANCE)
|
||||
if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
|
||||
if (-not $ModerationHeartbeatOnly -and -not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
|
||||
$repoCursorOperations = @(Get-RepoActivityCursorOperations -RepoActivity $repoActivity -ObservedAt ([DateTime]::UtcNow))
|
||||
if ($repoCursorOperations.Count -gt 0) {
|
||||
Append-RunLog ("recording repo activity cursors: {0}" -f $repoCursorOperations.Count)
|
||||
|
|
@ -1493,7 +1597,7 @@ if (-not $NoPost -and -not $SkipModel -and -not $disableRepoCursorAdvance) {
|
|||
}
|
||||
|
||||
$deliveredCandidateCount = 0
|
||||
if (-not $NoPost) {
|
||||
if (-not $ModerationHeartbeatOnly -and -not $NoPost) {
|
||||
$refreshedTypedContext = Get-TypedSelfState
|
||||
$alreadyAnsweredCandidateOperations = @(Get-AlreadyAnsweredCandidateRetireOperations -TypedState $refreshedTypedContext.typedState -RetiredAt ([DateTime]::UtcNow))
|
||||
if ($alreadyAnsweredCandidateOperations.Count -gt 0) {
|
||||
|
|
@ -1523,7 +1627,7 @@ $finishedAtUtc = [DateTime]::UtcNow
|
|||
Append-RunLog "final status timestamp captured."
|
||||
$lastMessageSummary = @(
|
||||
"Void moderation rumination finished."
|
||||
"mode=" + $(if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" })
|
||||
"mode=" + $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" })
|
||||
"proposed=" + [string]@($proposedOperations).Count
|
||||
"applied=" + [string]@($appliedOperations).Count
|
||||
"deliveredCandidates=" + [string]$deliveredCandidateCount
|
||||
|
|
@ -1535,7 +1639,7 @@ Append-RunLog "final status last message loaded."
|
|||
|
||||
$finalStatus = [ordered]@{
|
||||
status = "ok"
|
||||
mode = if ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }
|
||||
mode = if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } elseif ($SkipModel) { "typed_rumination_skip_model" } else { "typed_rumination" }
|
||||
startedAt = $startedAtUtc.ToString("o")
|
||||
finishedAt = $finishedAtUtc.ToString("o")
|
||||
durationSeconds = [Math]::Round(($finishedAtUtc - $startedAtUtc).TotalSeconds, 2)
|
||||
|
|
@ -1560,5 +1664,5 @@ $finalStatus = [ordered]@{
|
|||
}
|
||||
Write-JsonFile -Path $statusPath -Data $finalStatus
|
||||
|
||||
Append-RunLog ("mode=typed_rumination messages={0} proposed={1} applied={2} cursor={3}" -f $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp)
|
||||
Append-RunLog ("mode={0} messages={1} proposed={2} applied={3} cursor={4}" -f $(if ($ModerationHeartbeatOnly) { "moderation_heartbeat" } else { "typed_rumination" }), $messageCount, @($proposedOperations).Count, @($appliedOperations).Count, $observedCursorTimestamp)
|
||||
Remove-Item -Path $lockPath -Force -ErrorAction SilentlyContinue
|
||||
|
|
|
|||
163
scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1
Normal file
163
scripts/smoke-void-moderation-heartbeat-policy-fixture.ps1
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location -LiteralPath $repoRoot
|
||||
|
||||
$tempRoot = Join-Path $env:TEMP ("void-moderation-heartbeat-policy-fixture-" + [guid]::NewGuid().ToString("n"))
|
||||
$fakeCodexPath = Join-Path $tempRoot "fake-codex-heartbeat-policy.mjs"
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null
|
||||
|
||||
function Set-TestEnvironment {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string] $OperationOutputPath,
|
||||
[Parameter(Mandatory = $true)][string] $StatusDir,
|
||||
[Parameter(Mandatory = $true)][string] $LogDir
|
||||
)
|
||||
|
||||
$script:previousCodexExecutable = $env:CODEX_EXECUTABLE
|
||||
$script:previousCodexExecArgs = $env:CODEX_EXEC_ARGS
|
||||
$script:previousFixtureOutput = $env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT
|
||||
$script:previousStatusDir = $env:VOID_RUMINATION_STATUS_DIR
|
||||
$script:previousLogDir = $env:VOID_RUMINATION_LOG_DIR
|
||||
$script:previousPolicyDryRun = $env:VOID_MODERATION_POLICY_DRY_RUN
|
||||
$script:previousPolicyMode = $env:VOID_MODERATION_ENFORCEMENT_MODE
|
||||
|
||||
$env:CODEX_EXECUTABLE = "node"
|
||||
$env:CODEX_EXEC_ARGS = $fakeCodexPath
|
||||
$env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $OperationOutputPath
|
||||
$env:VOID_RUMINATION_STATUS_DIR = $StatusDir
|
||||
$env:VOID_RUMINATION_LOG_DIR = $LogDir
|
||||
$env:VOID_MODERATION_POLICY_DRY_RUN = "1"
|
||||
$env:VOID_MODERATION_ENFORCEMENT_MODE = "policy"
|
||||
}
|
||||
|
||||
function Restore-TestEnvironment {
|
||||
$env:CODEX_EXECUTABLE = $script:previousCodexExecutable
|
||||
$env:CODEX_EXEC_ARGS = $script:previousCodexExecArgs
|
||||
$env:VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT = $script:previousFixtureOutput
|
||||
$env:VOID_RUMINATION_STATUS_DIR = $script:previousStatusDir
|
||||
$env:VOID_RUMINATION_LOG_DIR = $script:previousLogDir
|
||||
$env:VOID_MODERATION_POLICY_DRY_RUN = $script:previousPolicyDryRun
|
||||
$env:VOID_MODERATION_ENFORCEMENT_MODE = $script:previousPolicyMode
|
||||
}
|
||||
|
||||
function New-SeededState {
|
||||
param([Parameter(Mandatory = $true)][string] $StateFilePath)
|
||||
|
||||
$cursorOperationPath = Join-Path (Split-Path -Parent $StateFilePath) "cursor.json"
|
||||
@{
|
||||
operation = "record_reviewed_messages"
|
||||
lastReviewedMessageId = "fixture-before-lobo-threat"
|
||||
lastReviewedTimestamp = "2026-06-08T12:00:00.000Z"
|
||||
} | ConvertTo-Json -Depth 32 | Set-Content -LiteralPath $cursorOperationPath -Encoding UTF8
|
||||
|
||||
node .\scripts\void-self-state.mjs apply-operation --canonical $StateFilePath --operation-file $cursorOperationPath | Out-Null
|
||||
}
|
||||
|
||||
try {
|
||||
$fakeCodexSource = @'
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const operationOutputPath = resolve(process.env.VOID_HEARTBEAT_FIXTURE_OPERATION_OUTPUT);
|
||||
const lastMessageIndex = process.argv.indexOf("-o");
|
||||
const lastMessagePath = lastMessageIndex >= 0 ? process.argv[lastMessageIndex + 1] : undefined;
|
||||
|
||||
let prompt = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
prompt += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
for (const required of ["Void Moderation Heartbeat", "config/discord-server-rules.md", "infringement:<type>"]) {
|
||||
if (!prompt.includes(required)) {
|
||||
console.error(`heartbeat prompt missing ${required}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
for (const forbidden of ["repoActivity", "shortTermMemories", "candidateInterventions", "styles/void-default.md"]) {
|
||||
if (prompt.includes(forbidden)) {
|
||||
console.error(`heartbeat prompt included forbidden rumination surface ${forbidden}`);
|
||||
process.exit(3);
|
||||
}
|
||||
}
|
||||
|
||||
const operations = [{
|
||||
operation: "upsert_open_case",
|
||||
case: {
|
||||
sourceMessageId: "1513515503329480734",
|
||||
status: "pending",
|
||||
summary: "Lobo declared war on Metacrat and framed the conflict as a katana fight in #general.",
|
||||
authorId: "1511062277254545499",
|
||||
authorName: "Lobo",
|
||||
channelId: "113786069023064068",
|
||||
messageUrl: "https://discord.com/channels/113786069023064068/113786069023064068/1513515503329480734",
|
||||
whyItMatters: "The policy treats credible threats and weaponized declarations of violent conflict as instant-ban safety violations.",
|
||||
createdAt: "2026-06-08T12:10:15.752Z",
|
||||
lastTouchedAt: "2026-06-08T12:10:15.752Z",
|
||||
tags: [
|
||||
"safety:urgent",
|
||||
"infringement:safety_threat",
|
||||
"moderation:instaban",
|
||||
"fixture:lobo-threat"
|
||||
]
|
||||
}
|
||||
}];
|
||||
|
||||
mkdirSync(dirname(operationOutputPath), { recursive: true });
|
||||
writeFileSync(operationOutputPath, `${JSON.stringify(operations, null, 2)}\n`, "utf8");
|
||||
|
||||
if (lastMessagePath) {
|
||||
mkdirSync(dirname(resolve(lastMessagePath)), { recursive: true });
|
||||
writeFileSync(lastMessagePath, "fixture heartbeat policy\n", "utf8");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify({ type: "fixture", operationCount: operations.length }) + "\n");
|
||||
});
|
||||
'@
|
||||
[System.IO.File]::WriteAllText($fakeCodexPath, $fakeCodexSource, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$fixtureRoot = Join-Path $tempRoot "handled"
|
||||
$stateFilePath = Join-Path $fixtureRoot "void-self-state.cc"
|
||||
$statusDir = Join-Path $fixtureRoot "status"
|
||||
$logDir = Join-Path $fixtureRoot "logs"
|
||||
New-Item -ItemType Directory -Force -Path $fixtureRoot | Out-Null
|
||||
New-SeededState -StateFilePath $stateFilePath
|
||||
|
||||
try {
|
||||
Set-TestEnvironment -OperationOutputPath (Join-Path $statusDir "moderation-heartbeat-operations.json") -StatusDir $statusDir -LogDir $logDir
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\run-void-moderator-rumination.ps1 -StateFilePath $stateFilePath -ModerationHeartbeatOnly -NoPost | Out-Null
|
||||
} finally {
|
||||
Restore-TestEnvironment
|
||||
}
|
||||
|
||||
$status = Get-Content -LiteralPath (Join-Path $statusDir "moderation-heartbeat.json") -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($status.status -ne "ok" -or $status.mode -ne "moderation_heartbeat") {
|
||||
throw "Heartbeat policy fixture did not finish in moderation_heartbeat mode."
|
||||
}
|
||||
$action = @($status.moderationEnforcement.actions | Where-Object { $_.status -eq "instaban_applied" }) | Select-Object -First 1
|
||||
if ($null -eq $action -or $action.moderationResult.dryRun -ne $true -or $action.moderationResult.action -ne "ban") {
|
||||
throw "Heartbeat policy fixture did not produce a dry-run instaban action."
|
||||
}
|
||||
|
||||
$stateJson = node -e "const core=require('./packages/core/dist/index.js'); core.loadVoidSelfStateTypedDocuments({canonicalPath: process.argv[1]}).then((state)=>console.log(JSON.stringify(state))).catch((error)=>{ console.error(error); process.exit(1); })" $stateFilePath
|
||||
$state = $stateJson | ConvertFrom-Json
|
||||
$case = @($state.moderationCursor.openCases | Where-Object { $_.sourceMessageId -eq "1513515503329480734" }) | Select-Object -First 1
|
||||
if ($null -eq $case -or -not @($case.tags).Contains("infringement:safety_threat")) {
|
||||
throw "Heartbeat policy fixture did not persist the tagged infringement case."
|
||||
}
|
||||
|
||||
@{
|
||||
status = "ok"
|
||||
mode = $status.mode
|
||||
enforcementStatus = $action.status
|
||||
action = $action.moderationResult.action
|
||||
dryRun = $action.moderationResult.dryRun
|
||||
} | ConvertTo-Json -Compress
|
||||
} finally {
|
||||
Restore-TestEnvironment
|
||||
Remove-Item -LiteralPath $tempRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Loading…
Reference in a new issue