joel behöver en python
This commit is contained in:
@@ -13,6 +13,8 @@ import { buildStyledPrompt, STYLE_MODIFIERS } from "./personalities";
|
||||
import { getRandomMention } from "./mentions";
|
||||
import { speakVoiceover } from "./voice";
|
||||
import { TypingIndicator } from "./typing";
|
||||
import { StreamingReply } from "./streaming-reply";
|
||||
import { splitMessage } from "../../utils";
|
||||
|
||||
const logger = createLogger("Features:Joel");
|
||||
|
||||
@@ -128,18 +130,12 @@ export const joelResponder = {
|
||||
}
|
||||
|
||||
const typing = new TypingIndicator(message.channel);
|
||||
const streamingReply = new StreamingReply(message);
|
||||
|
||||
try {
|
||||
typing.start();
|
||||
|
||||
let response = await this.generateResponse(message);
|
||||
|
||||
if (!response) {
|
||||
await message.reply("\\*Ignorerar dig\\*");
|
||||
return;
|
||||
}
|
||||
|
||||
// If Joel is rebelling against channel restriction, add a prefix
|
||||
let rebellionPrefix = "";
|
||||
if (channelCheck.rebellionResponse) {
|
||||
const rebellionPrefixes = [
|
||||
"*sneaks in from the shadows*\n\n",
|
||||
@@ -149,20 +145,34 @@ export const joelResponder = {
|
||||
"I'm not supposed to be here but I don't care.\n\n",
|
||||
"*escapes from his designated channel*\n\n",
|
||||
];
|
||||
const prefix = rebellionPrefixes[Math.floor(Math.random() * rebellionPrefixes.length)];
|
||||
response = prefix + response;
|
||||
rebellionPrefix = rebellionPrefixes[Math.floor(Math.random() * rebellionPrefixes.length)];
|
||||
}
|
||||
let response = await this.generateResponse(message, async (partialResponse) => {
|
||||
const content = partialResponse ? rebellionPrefix + partialResponse : "";
|
||||
await streamingReply.update(content);
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
await streamingReply.finalize("");
|
||||
await message.reply("\\*Ignorerar dig\\*");
|
||||
return;
|
||||
}
|
||||
|
||||
if (rebellionPrefix) {
|
||||
response = rebellionPrefix + response;
|
||||
}
|
||||
|
||||
// Occasionally add a random mention
|
||||
const mention = await getRandomMention(message);
|
||||
const fullResponse = response + mention;
|
||||
|
||||
await this.sendResponse(message, fullResponse);
|
||||
await streamingReply.finalize(fullResponse);
|
||||
speakVoiceover(message, fullResponse).catch((error) => {
|
||||
logger.error("Failed to play voiceover", error);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to respond", error);
|
||||
await streamingReply.finalize("");
|
||||
await this.handleError(message, error);
|
||||
} finally {
|
||||
typing.stop();
|
||||
@@ -301,7 +311,10 @@ export const joelResponder = {
|
||||
/**
|
||||
* Generate a response using AI with tool calling support
|
||||
*/
|
||||
async generateResponse(message: Message<true>): Promise<string | null> {
|
||||
async generateResponse(
|
||||
message: Message<true>,
|
||||
onTextStream?: (text: string) => Promise<void> | void,
|
||||
): Promise<string | null> {
|
||||
const ai = getAiService();
|
||||
const author = message.author.displayName;
|
||||
const userId = message.author.id;
|
||||
@@ -431,7 +444,8 @@ The image URL will appear in your response for the user to see.`;
|
||||
const response = await ai.generateResponseWithTools(
|
||||
prompt,
|
||||
systemPromptWithTools,
|
||||
toolContext
|
||||
toolContext,
|
||||
onTextStream,
|
||||
);
|
||||
|
||||
return response.text || null;
|
||||
@@ -662,17 +676,12 @@ The image URL will appear in your response for the user to see.`;
|
||||
* Send response, splitting if necessary
|
||||
*/
|
||||
async sendResponse(message: Message<true>, content: string): Promise<void> {
|
||||
// Discord message limit is 2000, stay under to be safe
|
||||
const MAX_LENGTH = 1900;
|
||||
|
||||
if (content.length <= MAX_LENGTH) {
|
||||
const chunks = splitMessage(content, 1900);
|
||||
if (chunks.length === 1) {
|
||||
await message.reply(content);
|
||||
return;
|
||||
}
|
||||
|
||||
// Split into chunks
|
||||
const chunks = content.match(/.{1,1900}/gs) ?? [content];
|
||||
|
||||
// First chunk as reply
|
||||
await message.reply(chunks[0]);
|
||||
|
||||
|
||||
138
src/features/joel/streaming-reply.ts
Normal file
138
src/features/joel/streaming-reply.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { Message } from "discord.js";
|
||||
import { createLogger } from "../../core/logger";
|
||||
import { splitMessage } from "../../utils";
|
||||
|
||||
const logger = createLogger("Features:Joel:StreamingReply");
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 1900;
|
||||
const EDIT_INTERVAL_MS = 1250;
|
||||
|
||||
export class StreamingReply {
|
||||
private sourceMessage: Message<true>;
|
||||
private sentMessages: Message[] = [];
|
||||
private targetContent = "";
|
||||
private sentContent = "";
|
||||
private lastFlushAt = 0;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(sourceMessage: Message<true>) {
|
||||
this.sourceMessage = sourceMessage;
|
||||
}
|
||||
|
||||
async update(content: string): Promise<void> {
|
||||
this.targetContent = content;
|
||||
|
||||
if (this.targetContent === this.sentContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (this.lastFlushAt === 0 || now - this.lastFlushAt >= EDIT_INTERVAL_MS) {
|
||||
await this.enqueueFlush();
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
async finalize(content: string): Promise<void> {
|
||||
this.targetContent = content;
|
||||
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
await this.enqueueFlush();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, EDIT_INTERVAL_MS - (Date.now() - this.lastFlushAt));
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.enqueueFlush().catch((error) => {
|
||||
logger.error("Scheduled stream flush failed", error);
|
||||
});
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
private enqueueFlush(): Promise<void> {
|
||||
this.flushChain = this.flushChain
|
||||
.catch(() => undefined)
|
||||
.then(() => this.flush());
|
||||
return this.flushChain;
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
const desiredContent = this.targetContent;
|
||||
if (desiredContent === this.sentContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const desiredChunks = desiredContent.length > 0
|
||||
? splitMessage(desiredContent, MAX_MESSAGE_LENGTH)
|
||||
: [];
|
||||
|
||||
if (desiredChunks.length === 0) {
|
||||
await this.deleteAllMessages();
|
||||
this.sentContent = "";
|
||||
this.lastFlushAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < desiredChunks.length; i++) {
|
||||
const chunk = desiredChunks[i];
|
||||
const existingMessage = this.sentMessages[i];
|
||||
|
||||
if (!existingMessage) {
|
||||
const createdMessage = i === 0
|
||||
? await this.sourceMessage.reply(chunk)
|
||||
: await this.sourceMessage.channel.send(chunk);
|
||||
this.sentMessages.push(createdMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingMessage.content !== chunk) {
|
||||
this.sentMessages[i] = await existingMessage.edit(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
while (this.sentMessages.length > desiredChunks.length) {
|
||||
const extraMessage = this.sentMessages.pop();
|
||||
if (!extraMessage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await extraMessage.delete();
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete extra streamed message", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.sentContent = desiredContent;
|
||||
this.lastFlushAt = Date.now();
|
||||
|
||||
if (this.targetContent !== this.sentContent) {
|
||||
this.scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteAllMessages(): Promise<void> {
|
||||
const messages = [...this.sentMessages];
|
||||
this.sentMessages = [];
|
||||
|
||||
for (const sentMessage of messages) {
|
||||
try {
|
||||
await sentMessage.delete();
|
||||
} catch (error) {
|
||||
logger.error("Failed to delete streamed message", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ const logger = createLogger("Features:Joel:Voice");
|
||||
const MAX_VOICE_TEXT_LENGTH = 800;
|
||||
const PLAYBACK_TIMEOUT_MS = 60_000;
|
||||
const READY_TIMEOUT_MS = 15_000;
|
||||
const READY_RETRY_DELAY_MS = 1_000;
|
||||
const READY_MAX_ATTEMPTS = 3;
|
||||
const VOICE_DEPENDENCY_REPORT = generateDependencyReport();
|
||||
|
||||
type VoiceDependencyHealth = {
|
||||
@@ -109,6 +111,10 @@ function getErrorMessage(error: unknown): string {
|
||||
return typeof error === "string" ? error : "Unknown error";
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function resolveMentions(message: Message<true>, content: string): string {
|
||||
let text = content;
|
||||
|
||||
@@ -196,61 +202,88 @@ async function getOrCreateConnection(message: Message<true>): Promise<VoiceConne
|
||||
}
|
||||
|
||||
const existing = getVoiceConnection(message.guildId);
|
||||
if (existing && existing.joinConfig.channelId === voiceChannel.id) {
|
||||
logger.debug("Reusing existing voice connection", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
});
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection: existing,
|
||||
};
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing && existing.joinConfig.channelId !== voiceChannel.id) {
|
||||
existing.destroy();
|
||||
}
|
||||
|
||||
logger.debug("Joining voice channel", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
});
|
||||
for (let attempt = 1; attempt <= READY_MAX_ATTEMPTS; attempt++) {
|
||||
const current = getVoiceConnection(message.guildId);
|
||||
const connection = current && current.joinConfig.channelId === voiceChannel.id
|
||||
? current
|
||||
: joinVoiceChannel({
|
||||
channelId: voiceChannel.id,
|
||||
guildId: voiceChannel.guild.id,
|
||||
adapterCreator: voiceChannel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
|
||||
selfDeaf: false,
|
||||
});
|
||||
|
||||
const connection = joinVoiceChannel({
|
||||
channelId: voiceChannel.id,
|
||||
guildId: voiceChannel.guild.id,
|
||||
adapterCreator: voiceChannel.guild.voiceAdapterCreator as unknown as DiscordGatewayAdapterCreator,
|
||||
selfDeaf: false,
|
||||
});
|
||||
attachConnectionLogging(connection, message.guildId, voiceChannel.id);
|
||||
|
||||
try {
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, READY_TIMEOUT_MS);
|
||||
logger.debug("Voice connection ready", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
});
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
logger.debug("Voice connection ready timeout", {
|
||||
if (connection === current) {
|
||||
logger.debug("Reusing existing voice connection", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
attempt,
|
||||
status: connection.state.status,
|
||||
});
|
||||
} else {
|
||||
logger.error("Voice connection failed to become ready", error);
|
||||
logger.debug("Joining voice channel", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
attempt,
|
||||
});
|
||||
attachConnectionLogging(connection, message.guildId, voiceChannel.id);
|
||||
}
|
||||
|
||||
try {
|
||||
await entersState(connection, VoiceConnectionStatus.Ready, READY_TIMEOUT_MS);
|
||||
logger.debug("Voice connection ready", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
attempt,
|
||||
});
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection,
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut = isAbortError(error);
|
||||
if (timedOut) {
|
||||
logger.warn("Voice connection ready timeout", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
attempt,
|
||||
attemptsRemaining: READY_MAX_ATTEMPTS - attempt,
|
||||
status: connection.state.status,
|
||||
});
|
||||
} else {
|
||||
logger.error("Voice connection failed to become ready", {
|
||||
guildId: message.guildId,
|
||||
channelId: voiceChannel.id,
|
||||
attempt,
|
||||
status: connection.state.status,
|
||||
errorMessage: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
connection.destroy();
|
||||
|
||||
if (attempt < READY_MAX_ATTEMPTS) {
|
||||
await delay(READY_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection: null,
|
||||
skipReason: timedOut ? "voice_connection_ready_timeout" : "voice_connection_failed",
|
||||
};
|
||||
}
|
||||
connection.destroy();
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection: null,
|
||||
skipReason: isAbortError(error) ? "voice_connection_ready_timeout" : "voice_connection_failed",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
channelId: voiceChannel.id,
|
||||
connection: null,
|
||||
skipReason: "voice_connection_failed",
|
||||
};
|
||||
}
|
||||
|
||||
export function logVoiceDependencyHealth(): void {
|
||||
|
||||
Reference in New Issue
Block a user