From d7366ff168087f04bbecbe20d00ec11484ed3f2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 17 May 2026 23:42:25 +0100 Subject: [PATCH] Support configured persona webhooks --- .env.example | 4 ++ apps/worker/src/mcp-server-discord.ts | 76 ++++++++++++++++++++++- scripts/run-void-moderator-rumination.ps1 | 9 ++- scripts/send-discord-message.mjs | 74 +++++++++++++++++++++- state/evidence.jsonl | 1 + 5 files changed, 157 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index ef09df1..0dabfee 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ DISCORD_APPLICATION_ID= DISCORD_GUILD_ID= DISCORD_OWNER_ID= DISCORD_BOT_TRIGGER_ROLE_IDS= +# Optional persona webhook pipe. Prefer channel-specific URLs; the bot otherwise +# needs Manage Webhooks in the target channel to create its own cached webhook. +# DISCORD_PERSONA_WEBHOOK_URL_=https://discord.com/api/webhooks// +DISCORD_PERSONA_WEBHOOK_URL= STATE_STORAGE_BACKEND=postgres DATABASE_DSN=postgres://voidbot:voidbot@localhost:5432/voidbot # Leave this as local_json until Qdrant is running and you've migrated the existing vectors. diff --git a/apps/worker/src/mcp-server-discord.ts b/apps/worker/src/mcp-server-discord.ts index 956e534..f2cd8b4 100644 --- a/apps/worker/src/mcp-server-discord.ts +++ b/apps/worker/src/mcp-server-discord.ts @@ -24,6 +24,7 @@ interface CachedWebhookRecord { channelId: string; name: string; createdAt: string; + configured?: boolean; } export async function openOwnerDmChannel( @@ -114,7 +115,8 @@ async function postDiscordPersonaMessage( replyToMessageId?: string, ): Promise<{ id: string; transport: "webhook" }> { const target = await resolveWebhookTarget(botToken, channelId); - let webhook = getCachedPersonaWebhook(target.webhookChannelId); + const configuredWebhook = await getConfiguredPersonaWebhook(target.webhookChannelId); + let webhook = configuredWebhook ?? getCachedPersonaWebhook(target.webhookChannelId); if (!webhook) { webhook = await createPersonaWebhook(botToken, target.webhookChannelId); @@ -134,6 +136,14 @@ async function postDiscordPersonaMessage( throw error; } + if (configuredWebhook) { + throw new Error( + `Configured persona webhook for channel ${target.webhookChannelId} is no longer executable: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + clearCachedPersonaWebhook(target.webhookChannelId); const refreshedWebhook = await createPersonaWebhook(botToken, target.webhookChannelId); writeCachedPersonaWebhook(target.webhookChannelId, refreshedWebhook); @@ -215,7 +225,11 @@ async function createPersonaWebhook( }); if (!response.ok) { - throw new Error(`Failed to create Discord webhook: ${response.status} ${await response.text()}`); + const detail = await response.text(); + throw new Error( + `Failed to create Discord webhook for channel ${channelId}: ${response.status} ${detail}. ` + + `Grant the bot Manage Webhooks there or configure DISCORD_PERSONA_WEBHOOK_URL_${channelId}.`, + ); } const payload = (await response.json()) as { id?: string; token?: string }; @@ -233,6 +247,64 @@ async function createPersonaWebhook( }; } +async function getConfiguredPersonaWebhook(channelId: string): Promise { + const channelSpecificKey = `DISCORD_PERSONA_WEBHOOK_URL_${channelId}`; + const rawUrl = trimOptionalString(process.env[channelSpecificKey]) ?? trimOptionalString(process.env.DISCORD_PERSONA_WEBHOOK_URL); + + if (!rawUrl) { + return undefined; + } + + const webhook = parseDiscordWebhookUrl(rawUrl, channelId); + await assertConfiguredWebhookTargetsChannel(webhook, channelId); + return webhook; +} + +function parseDiscordWebhookUrl(rawUrl: string, expectedChannelId: string): CachedWebhookRecord { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Configured persona webhook for channel ${expectedChannelId} is not a valid URL.`); + } + + const match = url.pathname.match(/\/api(?:\/v\d+)?\/webhooks\/([^/]+)\/([^/?#]+)/) ?? + url.pathname.match(/\/webhooks\/([^/]+)\/([^/?#]+)/); + + if (!match) { + throw new Error( + `Configured persona webhook for channel ${expectedChannelId} must look like https://discord.com/api/webhooks//.`, + ); + } + + return { + id: match[1], + token: match[2], + channelId: expectedChannelId, + name: "configured", + createdAt: "configured", + configured: true, + }; +} + +async function assertConfiguredWebhookTargetsChannel( + webhook: CachedWebhookRecord, + expectedChannelId: string, +): Promise { + const response = await fetch(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`); + + if (!response.ok) { + throw new Error(`Configured persona webhook lookup failed: ${response.status} ${await response.text()}`); + } + + const payload = (await response.json()) as { channel_id?: string }; + if (payload.channel_id !== expectedChannelId) { + throw new Error( + `Configured persona webhook targets channel ${payload.channel_id}, not required channel ${expectedChannelId}.`, + ); + } +} + async function executePersonaWebhook( webhook: CachedWebhookRecord, { diff --git a/scripts/run-void-moderator-rumination.ps1 b/scripts/run-void-moderator-rumination.ps1 index c52ca3e..ac36cac 100644 --- a/scripts/run-void-moderator-rumination.ps1 +++ b/scripts/run-void-moderator-rumination.ps1 @@ -610,9 +610,14 @@ function Invoke-CandidateInterventionDeliveryFromIntervention { $previousVoidStatusDir = $env:VOID_STATUS_DIR try { $env:VOID_STATUS_DIR = $statusDir - & node @arguments | Out-Null + $sendOutput = & node @arguments 2>&1 if ($LASTEXITCODE -ne 0) { - throw "Candidate intervention delivery failed with exit code $LASTEXITCODE." + $sendDetail = [string]($sendOutput -join [Environment]::NewLine) + if ([string]::IsNullOrWhiteSpace($sendDetail)) { + throw "Candidate intervention delivery failed with exit code $LASTEXITCODE." + } + + throw "Candidate intervention delivery failed with exit code $LASTEXITCODE`: $($sendDetail.Trim())" } } finally { $env:VOID_STATUS_DIR = $previousVoidStatusDir diff --git a/scripts/send-discord-message.mjs b/scripts/send-discord-message.mjs index 896aa78..6d39f31 100644 --- a/scripts/send-discord-message.mjs +++ b/scripts/send-discord-message.mjs @@ -59,6 +59,7 @@ async function main() { content, options.replyToMessageId, persona, + env, ); writeLastSpeechStatus({ sentAt: new Date().toISOString(), @@ -239,13 +240,14 @@ async function postChunkedDiscordMessage( content, replyToMessageId, persona, + env, ) { const chunks = splitDiscordContent(content); for (let index = 0; index < chunks.length; index += 1) { const replyTarget = index === 0 ? replyToMessageId : undefined; if (persona) { - await postDiscordWebhookMessage(botToken, channelId, chunks[index], replyTarget, persona); + await postDiscordWebhookMessage(botToken, channelId, chunks[index], replyTarget, persona, env); } else { await postDiscordMessage( botToken, @@ -289,9 +291,11 @@ async function postDiscordWebhookMessage( content, replyToMessageId, persona, + env, ) { const target = await resolveWebhookTarget(botToken, channelId); - let webhook = await getCachedPersonaWebhook(target.webhookChannelId); + const configuredWebhook = await getConfiguredPersonaWebhook(env, target.webhookChannelId); + let webhook = configuredWebhook ?? await getCachedPersonaWebhook(target.webhookChannelId); if (!webhook) { webhook = await createPersonaWebhook(botToken, target.webhookChannelId); @@ -311,6 +315,12 @@ async function postDiscordWebhookMessage( throw error; } + if (configuredWebhook) { + throw new Error( + `Configured persona webhook for channel ${target.webhookChannelId} is no longer executable: ${error.message}`, + ); + } + clearCachedPersonaWebhook(target.webhookChannelId); const refreshedWebhook = await createPersonaWebhook(botToken, target.webhookChannelId); writeCachedPersonaWebhook(target.webhookChannelId, refreshedWebhook); @@ -379,7 +389,11 @@ async function createPersonaWebhook(botToken, channelId) { }); if (!response.ok) { - throw new Error(`Failed to create Discord webhook: ${response.status} ${await response.text()}`); + const detail = await response.text(); + throw new Error( + `Failed to create Discord webhook for channel ${channelId}: ${response.status} ${detail}. ` + + `Grant the bot Manage Webhooks there or configure DISCORD_PERSONA_WEBHOOK_URL_${channelId}.`, + ); } const payload = await response.json(); @@ -397,6 +411,60 @@ async function createPersonaWebhook(botToken, channelId) { }; } +async function getConfiguredPersonaWebhook(env, channelId) { + const channelSpecificKey = `DISCORD_PERSONA_WEBHOOK_URL_${channelId}`; + const rawUrl = trimOptionalString(env[channelSpecificKey]) ?? trimOptionalString(env.DISCORD_PERSONA_WEBHOOK_URL); + + if (!rawUrl) { + return undefined; + } + + const webhook = parseDiscordWebhookUrl(rawUrl, channelId); + await assertConfiguredWebhookTargetsChannel(webhook, channelId); + return webhook; +} + +function parseDiscordWebhookUrl(rawUrl, expectedChannelId) { + let url; + try { + url = new URL(rawUrl); + } catch { + throw new Error(`Configured persona webhook for channel ${expectedChannelId} is not a valid URL.`); + } + + const match = url.pathname.match(/\/api(?:\/v\d+)?\/webhooks\/([^/]+)\/([^/?#]+)/) ?? + url.pathname.match(/\/webhooks\/([^/]+)\/([^/?#]+)/); + + if (!match) { + throw new Error( + `Configured persona webhook for channel ${expectedChannelId} must look like https://discord.com/api/webhooks//.`, + ); + } + + return { + id: match[1], + token: match[2], + channelId: expectedChannelId, + name: "configured", + configured: true, + }; +} + +async function assertConfiguredWebhookTargetsChannel(webhook, expectedChannelId) { + const response = await fetch(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`); + + if (!response.ok) { + throw new Error(`Configured persona webhook lookup failed: ${response.status} ${await response.text()}`); + } + + const payload = await response.json(); + if (payload.channel_id !== expectedChannelId) { + throw new Error( + `Configured persona webhook targets channel ${payload.channel_id}, not required channel ${expectedChannelId}.`, + ); + } +} + async function executePersonaWebhook( webhook, { threadId, content, replyToMessageId, username, avatarUrl }, diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 2597ee4..164414d 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -125,3 +125,4 @@ {"ts":"2026-05-17T18:21:36Z","type":"implementation","status":"ok","note":"Added agency-to-speech accountability: rumination now projects high-intensity active self/world advocacy pressure as speechPressureObligations and rejects silent [] unless the model queues/deferred a candidate or cools/retires the pressure. The fixture proves silent output fails and deferred candidate output passes."} {"ts":"2026-05-17T18:47:03Z","type":"implementation","status":"ok","note":"Closed the stranded-candidate speech gap. Metacrat's explicit channel permission was observed at 2026-05-17T18:15:24Z, but rumination queued the AquariumSynthHost draft as deferred with no delivery target, then nap skip left it unsent. The runner now treats deferred candidates as unresolved pressure, delivers existing queued candidates with delivery targets, and the stranded candidate was repaired to the permitted channel and posted with a typed receipt."} {"ts":"2026-05-17T19:15:56Z","type":"implementation","status":"ok","note":"Live-loop inspection complaints resolved: candidate speech receipts now link back to the spoken intervention, rumination report text is refreshed by the runner, watchdog Repixelizer SSH checks no longer hang indefinitely, and offsite sync recovered with a fresh remote archive."} +{"ts":"2026-05-17T22:41:56Z","type":"implementation","status":"ok","note":"Persona webhook delivery now supports configured per-channel webhook URLs and preserves Discord failure details in the parent rumination runner; live probe confirms current #general blocker is Discord 403 Missing Permissions unless Manage Webhooks is granted or DISCORD_PERSONA_WEBHOOK_URL_113786069023064068 is configured."}