Bifrost/tools/bifrost-discord-persona-ingress.mjs
2026-07-22 00:34:04 +01:00

107 lines
7 KiB
JavaScript

import { createHash } from "node:crypto";
const GATEWAY_VERSION = 10;
const GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 15);
export function conversationFromDiscordMessage(message, botUserId) {
if (!message || typeof message !== "object" || message.author?.bot === true) return null;
const guildId = text(message.guild_id);
const channelId = text(message.channel_id);
const messageId = text(message.id);
const authorId = text(message.author?.id);
const observedAt = Date.parse(String(message.timestamp ?? ""));
if (!guildId || !channelId || !messageId || !authorId || !botUserId || !Number.isFinite(observedAt)) return null;
const mentioned = Array.isArray(message.mentions)
&& message.mentions.some((mention) => String(mention?.id ?? "") === botUserId);
const replied = String(message.referenced_message?.author?.id ?? "") === botUserId;
if (!mentioned && !replied) return null;
const content = String(message.content ?? "")
.replace(new RegExp(`<@!?${escapeRegex(botUserId)}>`, "g"), " ")
.replace(/\s+/g, " ")
.trim();
if (!content || Buffer.byteLength(content, "utf8") > 1200) return null;
return {
guildId,
channelId,
messageId,
authorId,
authorName: text(message.member?.nick) || text(message.author?.global_name) || text(message.author?.username) || authorId,
content,
addressingMode: mentioned ? "text" : "reply",
observedAt: new Date(observedAt).toISOString(),
payloadHash: createHash("sha256").update(content, "utf8").digest("hex"),
};
}
export function discordGatewayReady(state, nowMillis = Date.now(), maxAckAgeMillis = 120000) {
return state?.connected === true
&& text(state.sessionId) !== ""
&& Number.isFinite(state.lastHeartbeatAckAtMillis)
&& state.lastHeartbeatAckAtMillis > 0
&& nowMillis >= state.lastHeartbeatAckAtMillis
&& nowMillis - state.lastHeartbeatAckAtMillis <= maxAckAgeMillis;
}
export async function serveDiscordPersonaIngress({ token, runtimeId, onConversation, onReady = () => {}, onDisconnect = () => {}, onState = () => {} }) {
if (!text(token)) throw new Error("Bifrost Discord Persona ingress requires a bot token.");
if (!text(runtimeId)) throw new Error("Bifrost Discord Persona ingress requires a runtime id.");
if (typeof onConversation !== "function") throw new Error("Bifrost Discord Persona ingress requires a conversation sink.");
onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovering"});
const gateway = await discordJson("https://discord.com/api/v10/gateway/bot", token);
if (!text(gateway?.url)) throw new Error("Discord gateway discovery returned no URL.");
onState({connected:false,sessionId:"",lastHeartbeatAckAtMillis:0,phase:"discovered"});
let sequence = null, sessionId = "", resumeUrl = gateway.url, botUserId = "", stopped = false;
while (!stopped) {
try {
const result = await gatewaySession({ token, runtimeId, url: resumeUrl, sequence, sessionId, botUserId, onConversation, onReady, onState });
sequence = result.sequence; sessionId = result.sessionId; resumeUrl = result.resumeUrl; botUserId = result.botUserId;
stopped = result.stopped;
} catch (error) {
if (error?.fatal === true) throw error;
onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"});
onDisconnect(error);
await delay(2000);
}
}
}
async function gatewaySession(state) {
const socket = new WebSocket(`${state.url}/?v=${GATEWAY_VERSION}&encoding=json`);
let sequence=state.sequence,sessionId=state.sessionId,resumeUrl=state.url,botUserId=state.botUserId,heartbeatTimer=null,heartbeatAck=true,stopped=false;
await new Promise((resolve, reject) => {
const cleanup=()=>{if(heartbeatTimer)clearInterval(heartbeatTimer);};
socket.addEventListener("error",()=>{cleanup();reject(new Error("Discord gateway socket failed."));},{once:true});
socket.addEventListener("close",(event)=>{cleanup();state.onState({connected:false,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"disconnected"});if([4004,4013,4014].includes(event.code)){stopped=true;const error=new Error(`Discord permanently rejected the Bifrost gateway session (close ${event.code}).`);error.fatal=true;reject(error);return;}resolve();},{once:true});
socket.addEventListener("message",async event=>{
try {
const frame=JSON.parse(String(event.data));
if(Number.isInteger(frame.s))sequence=frame.s;
if(frame.op===10){
const interval=Number(frame.d?.heartbeat_interval);if(!Number.isFinite(interval)||interval<1000)throw new Error("Discord gateway supplied an invalid heartbeat interval.");
const heartbeat=()=>{if(!heartbeatAck){socket.close(4000,"heartbeat timeout");return;}heartbeatAck=false;socket.send(JSON.stringify({op:1,d:sequence}));};
heartbeatTimer=setInterval(heartbeat,interval);heartbeat();
if(sessionId&&sequence!==null)socket.send(JSON.stringify({op:6,d:{token:state.token,session_id:sessionId,seq:sequence}}));
else socket.send(JSON.stringify({op:2,d:{token:state.token,intents:GATEWAY_INTENTS,properties:{os:process.platform,browser:"bifrost",device:"bifrost"}}}));
} else if(frame.op===11){heartbeatAck=true;state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:Date.now(),phase:"heartbeat-acknowledged"});}
else if(frame.op===7)socket.close(4000,"server reconnect");
else if(frame.op===9){sessionId="";sequence=null;socket.close(4000,"invalid session");}
else if(frame.op===0&&frame.t==="READY"){
sessionId=text(frame.d?.session_id);resumeUrl=text(frame.d?.resume_gateway_url)||state.url;botUserId=text(frame.d?.user?.id);if(!sessionId||!botUserId)throw new Error("Discord READY omitted session or bot identity.");state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"ready-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId});
} else if(frame.op===0&&frame.t==="RESUMED"){state.onState({connected:true,sessionId,botUserId,lastHeartbeatAckAtMillis:0,phase:"resumed-awaiting-heartbeat-ack"});await state.onReady({runtimeId:state.runtimeId,botUserId,sessionId});}
else if(frame.op===0&&frame.t==="MESSAGE_CREATE"){
const conversation=conversationFromDiscordMessage(frame.d,botUserId);if(conversation)await state.onConversation(conversation);
}
} catch(error){socket.close(4000,"ingress failure");reject(error);}
});
});
return {sequence,sessionId,resumeUrl,botUserId,stopped};
}
async function discordJson(url, token) {
const response=await fetch(url,{headers:{authorization:`Bot ${token}`},redirect:"error",signal:AbortSignal.timeout(15000)});
if(!response.ok)throw new Error(`Discord gateway discovery failed with HTTP ${response.status}.`);
return response.json();
}
function text(value){return typeof value==="string"?value.trim():"";}
function escapeRegex(value){return value.replace(/[.*+?^${}()|[\]\\]/g,"\\$&");}
function delay(ms){return new Promise(resolve=>setTimeout(resolve,ms));}