Add shared Discord persona webhook pipe
This commit is contained in:
parent
c2ac081712
commit
97650aaafe
8 changed files with 603 additions and 17 deletions
|
|
@ -3,11 +3,18 @@ import { dirname, resolve } from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PERSONA_WEBHOOK_NAME = "VoidBot Persona Pipe";
|
||||
const PERSONA_WEBHOOK_CACHE_PATH = resolve(
|
||||
repoRoot,
|
||||
".voidbot/private/discord-webhook-cache.json",
|
||||
);
|
||||
const THREAD_CHANNEL_TYPES = new Set([10, 11, 12]);
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const env = readLocalEnv();
|
||||
const botToken = env.DISCORD_BOT_TOKEN?.trim();
|
||||
const persona = normalizePersonaOptions(options);
|
||||
|
||||
if (!botToken) {
|
||||
throw new Error("DISCORD_BOT_TOKEN is required.");
|
||||
|
|
@ -27,10 +34,11 @@ async function main() {
|
|||
}
|
||||
|
||||
const channelId = await openOwnerDmChannel(botToken, ownerId);
|
||||
await postChunkedDiscordMessage(botToken, channelId, content);
|
||||
await postChunkedDiscordMessage(botToken, channelId, content, undefined, undefined);
|
||||
writeLastSpeechStatus({
|
||||
sentAt: new Date().toISOString(),
|
||||
mode: "owner_dm",
|
||||
transport: "bot",
|
||||
channelId,
|
||||
contentLength: content.length,
|
||||
chunkCount: splitDiscordContent(content).length,
|
||||
|
|
@ -49,12 +57,16 @@ async function main() {
|
|||
options.channelId,
|
||||
content,
|
||||
options.replyToMessageId,
|
||||
persona,
|
||||
);
|
||||
writeLastSpeechStatus({
|
||||
sentAt: new Date().toISOString(),
|
||||
mode: "channel",
|
||||
transport: persona ? "webhook" : "bot",
|
||||
channelId: options.channelId,
|
||||
replyToMessageId: options.replyToMessageId ?? null,
|
||||
personaName: persona?.personaName ?? null,
|
||||
personaAvatarUrl: persona?.personaAvatarUrl ?? null,
|
||||
contentLength: content.length,
|
||||
chunkCount: splitDiscordContent(content).length,
|
||||
preview: content.slice(0, 280),
|
||||
|
|
@ -64,8 +76,10 @@ async function main() {
|
|||
`${JSON.stringify({
|
||||
ok: true,
|
||||
mode: "channel",
|
||||
transport: persona ? "webhook" : "bot",
|
||||
channelId: options.channelId,
|
||||
replyToMessageId: options.replyToMessageId ?? null,
|
||||
personaName: persona?.personaName ?? null,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
|
@ -77,6 +91,8 @@ function parseArgs(args) {
|
|||
replyToMessageId: undefined,
|
||||
content: undefined,
|
||||
contentFile: undefined,
|
||||
personaName: undefined,
|
||||
personaAvatarUrl: undefined,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
|
|
@ -102,6 +118,14 @@ function parseArgs(args) {
|
|||
options.contentFile = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
case "--persona-name":
|
||||
options.personaName = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
case "--persona-avatar-url":
|
||||
options.personaAvatarUrl = args[index + 1];
|
||||
index += 1;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
|
|
@ -208,16 +232,27 @@ async function openOwnerDmChannel(botToken, recipientId) {
|
|||
return payload.id;
|
||||
}
|
||||
|
||||
async function postChunkedDiscordMessage(botToken, channelId, content, replyToMessageId) {
|
||||
async function postChunkedDiscordMessage(
|
||||
botToken,
|
||||
channelId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
persona,
|
||||
) {
|
||||
const chunks = splitDiscordContent(content);
|
||||
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
await postDiscordMessage(
|
||||
botToken,
|
||||
channelId,
|
||||
chunks[index],
|
||||
index === 0 ? replyToMessageId : undefined,
|
||||
);
|
||||
const replyTarget = index === 0 ? replyToMessageId : undefined;
|
||||
if (persona) {
|
||||
await postDiscordWebhookMessage(botToken, channelId, chunks[index], replyTarget, persona);
|
||||
} else {
|
||||
await postDiscordMessage(
|
||||
botToken,
|
||||
channelId,
|
||||
chunks[index],
|
||||
replyTarget,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,6 +282,232 @@ async function postDiscordMessage(botToken, channelId, content, replyToMessageId
|
|||
}
|
||||
}
|
||||
|
||||
async function postDiscordWebhookMessage(
|
||||
botToken,
|
||||
channelId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
persona,
|
||||
) {
|
||||
const target = await resolveWebhookTarget(botToken, channelId);
|
||||
let webhook = await getCachedPersonaWebhook(target.webhookChannelId);
|
||||
|
||||
if (!webhook) {
|
||||
webhook = await createPersonaWebhook(botToken, target.webhookChannelId);
|
||||
writeCachedPersonaWebhook(target.webhookChannelId, webhook);
|
||||
}
|
||||
|
||||
try {
|
||||
await executePersonaWebhook(webhook, {
|
||||
threadId: target.threadId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
username: persona.personaName,
|
||||
avatarUrl: persona.personaAvatarUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStaleWebhookError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
clearCachedPersonaWebhook(target.webhookChannelId);
|
||||
const refreshedWebhook = await createPersonaWebhook(botToken, target.webhookChannelId);
|
||||
writeCachedPersonaWebhook(target.webhookChannelId, refreshedWebhook);
|
||||
await executePersonaWebhook(refreshedWebhook, {
|
||||
threadId: target.threadId,
|
||||
content,
|
||||
replyToMessageId,
|
||||
username: persona.personaName,
|
||||
avatarUrl: persona.personaAvatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveWebhookTarget(botToken, channelId) {
|
||||
const channel = await getDiscordChannel(botToken, channelId);
|
||||
|
||||
if (THREAD_CHANNEL_TYPES.has(channel.type)) {
|
||||
if (!channel.parent_id) {
|
||||
throw new Error(`Discord thread ${channelId} has no parent channel for webhook routing.`);
|
||||
}
|
||||
|
||||
return {
|
||||
webhookChannelId: channel.parent_id,
|
||||
threadId: channel.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
webhookChannelId: channel.id,
|
||||
threadId: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDiscordChannel(botToken, channelId) {
|
||||
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Discord channel: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!payload?.id || typeof payload.type !== "number") {
|
||||
throw new Error(`Discord channel lookup for ${channelId} returned malformed data.`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function createPersonaWebhook(botToken, channelId) {
|
||||
const response = await fetch(`https://discord.com/api/v10/channels/${channelId}/webhooks`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${botToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: PERSONA_WEBHOOK_NAME,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Discord webhook: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!payload?.id || !payload?.token) {
|
||||
throw new Error(`Discord webhook creation for channel ${channelId} returned no executable token.`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.id,
|
||||
token: payload.token,
|
||||
channelId,
|
||||
name: PERSONA_WEBHOOK_NAME,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function executePersonaWebhook(
|
||||
webhook,
|
||||
{ threadId, content, replyToMessageId, username, avatarUrl },
|
||||
) {
|
||||
const url = new URL(`https://discord.com/api/v10/webhooks/${webhook.id}/${webhook.token}`);
|
||||
url.searchParams.set("wait", "true");
|
||||
|
||||
if (threadId) {
|
||||
url.searchParams.set("thread_id", threadId);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
username,
|
||||
avatar_url: avatarUrl,
|
||||
message_reference: replyToMessageId
|
||||
? {
|
||||
message_id: replyToMessageId,
|
||||
fail_if_not_exists: false,
|
||||
}
|
||||
: undefined,
|
||||
allowed_mentions: {
|
||||
parse: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to execute Discord webhook: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePersonaOptions(options) {
|
||||
const personaName = trimOptionalString(options.personaName);
|
||||
const personaAvatarUrl = trimOptionalString(options.personaAvatarUrl);
|
||||
|
||||
if (!personaName && !personaAvatarUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!personaName) {
|
||||
throw new Error("personaName is required when using the shared persona webhook pipe.");
|
||||
}
|
||||
|
||||
return {
|
||||
personaName: personaName.slice(0, 80),
|
||||
personaAvatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function trimOptionalString(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function getCachedPersonaWebhook(channelId) {
|
||||
const cache = readPersonaWebhookCache();
|
||||
return cache[channelId];
|
||||
}
|
||||
|
||||
function writeCachedPersonaWebhook(channelId, webhook) {
|
||||
const cache = readPersonaWebhookCache();
|
||||
cache[channelId] = webhook;
|
||||
writePersonaWebhookCache(cache);
|
||||
}
|
||||
|
||||
function clearCachedPersonaWebhook(channelId) {
|
||||
const cache = readPersonaWebhookCache();
|
||||
if (cache[channelId]) {
|
||||
delete cache[channelId];
|
||||
writePersonaWebhookCache(cache);
|
||||
}
|
||||
}
|
||||
|
||||
function readPersonaWebhookCache() {
|
||||
try {
|
||||
const raw = stripLeadingBom(readFileSync(PERSONA_WEBHOOK_CACHE_PATH, "utf8"));
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (error instanceof SyntaxError) {
|
||||
return {};
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersonaWebhookCache(cache) {
|
||||
mkdirSync(dirname(PERSONA_WEBHOOK_CACHE_PATH), { recursive: true });
|
||||
writeFileSync(PERSONA_WEBHOOK_CACHE_PATH, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
function isStaleWebhookError(error) {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
error.message.includes("Failed to execute Discord webhook: 401") ||
|
||||
error.message.includes("Failed to execute Discord webhook: 404")
|
||||
);
|
||||
}
|
||||
|
||||
function splitDiscordContent(input, limit = 1900) {
|
||||
const normalized = input.replace(/\r\n/g, "\n").trim();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue