Support configured persona webhooks
This commit is contained in:
parent
c7dbd0e42e
commit
d7366ff168
5 changed files with 157 additions and 7 deletions
|
|
@ -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_<channel_id>=https://discord.com/api/webhooks/<id>/<token>
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<CachedWebhookRecord | undefined> {
|
||||
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/<id>/<token>.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: match[1],
|
||||
token: match[2],
|
||||
channelId: expectedChannelId,
|
||||
name: "configured",
|
||||
createdAt: "configured",
|
||||
configured: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertConfiguredWebhookTargetsChannel(
|
||||
webhook: CachedWebhookRecord,
|
||||
expectedChannelId: string,
|
||||
): Promise<void> {
|
||||
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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/<id>/<token>.`,
|
||||
);
|
||||
}
|
||||
|
||||
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 },
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
|
|
|||
Loading…
Reference in a new issue