diff --git a/src/core/ai/openai/dto/speech-to-text.dto.ts b/src/core/ai/openai/dto/speech-to-text.dto.ts index 7d94051..49be92b 100644 --- a/src/core/ai/openai/dto/speech-to-text.dto.ts +++ b/src/core/ai/openai/dto/speech-to-text.dto.ts @@ -1,11 +1,11 @@ import { - IsString, IsIn, - IsOptional, + IsMimeType, IsNumber, - Min, + IsOptional, + IsString, Max, - IsMimeType, + Min, } from 'class-validator'; import { Type } from 'class-transformer'; diff --git a/src/core/ai/openai/dto/text-to-speech.dto.ts b/src/core/ai/openai/dto/text-to-speech.dto.ts index dec44a4..be89371 100644 --- a/src/core/ai/openai/dto/text-to-speech.dto.ts +++ b/src/core/ai/openai/dto/text-to-speech.dto.ts @@ -1,10 +1,10 @@ import { - IsString, IsIn, - IsOptional, IsNumber, - Min, + IsOptional, + IsString, Max, + Min, } from 'class-validator'; import { Type } from 'class-transformer'; diff --git a/src/core/ai/openai/openai-audio.service.ts b/src/core/ai/openai/openai-audio.service.ts index 47ffc4c..52dc88e 100644 --- a/src/core/ai/openai/openai-audio.service.ts +++ b/src/core/ai/openai/openai-audio.service.ts @@ -1,6 +1,6 @@ import { OpenaiSecretsService } from './openai-secrets.service'; import { OpenAI } from 'openai'; -import { createReadStream, promises } from 'fs'; +import { promises } from 'fs'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { TextToSpeechDto } from './dto/text-to-speech.dto'; import * as path from 'path'; diff --git a/src/core/chatbot/chatbot-response.service.ts b/src/core/chatbot/chatbot-response.service.ts index ac0005b..3de9bfe 100644 --- a/src/core/chatbot/chatbot-response.service.ts +++ b/src/core/chatbot/chatbot-response.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ChatbotManagerService } from './chatbot-manager.service'; import { OpenaiChatService } from '../ai/openai/openai-chat.service'; + class StreamResponseOptions { /** * Character limit to split the outgoing data diff --git a/src/core/database/collections/ai-chats/ai-chat.schema.ts b/src/core/database/collections/ai-chats/ai-chat.schema.ts index 34d3e39..4d3bf48 100644 --- a/src/core/database/collections/ai-chats/ai-chat.schema.ts +++ b/src/core/database/collections/ai-chats/ai-chat.schema.ts @@ -3,7 +3,6 @@ import { Document, Types } from 'mongoose'; import { ContextRoute } from './context-route.provider'; import { User } from '../users/user.schema'; import { Couplet } from '../couplets/couplet.schema'; -import { OpenAiChatConfig } from '../../../ai/openai/openai-chat.service'; export type AiChatDocument = AiChat & Document; diff --git a/src/interfaces/discord/services/events/discord-on-message.service.ts b/src/interfaces/discord/services/events/discord-on-message.service.ts index 72c7072..c9d1100 100644 --- a/src/interfaces/discord/services/events/discord-on-message.service.ts +++ b/src/interfaces/discord/services/events/discord-on-message.service.ts @@ -92,7 +92,11 @@ export class DiscordOnMessageService { if (!this.activeChats.has(message.channel.id)) { await this._reloadChatFromDatabase(message); } - await this._messageService.respondToMessage(message, message.author.id); + await this._messageService.respondToMessage( + message, + message, + message.author.id, + ); } private async _reloadChatFromDatabase(message: Message) { diff --git a/src/interfaces/discord/services/threads/discord-attachment.service.ts b/src/interfaces/discord/services/threads/discord-attachment.service.ts index 0e3bb38..12b1bde 100644 --- a/src/interfaces/discord/services/threads/discord-attachment.service.ts +++ b/src/interfaces/discord/services/threads/discord-attachment.service.ts @@ -3,10 +3,11 @@ import { Attachment } from 'discord.js'; import { OpenaiAudioService } from '../../../../core/ai/openai/openai-audio.service'; import axios from 'axios'; import * as path from 'path'; +import * as fs from 'fs'; import { createReadStream, createWriteStream } from 'fs'; import { promisify } from 'util'; import * as stream from 'stream'; -import * as fs from 'fs'; + @Injectable() export class DiscordAttachmentService { constructor( diff --git a/src/interfaces/discord/services/threads/discord-chat.service.ts b/src/interfaces/discord/services/threads/discord-chat.service.ts index 52073c1..d4ce933 100644 --- a/src/interfaces/discord/services/threads/discord-chat.service.ts +++ b/src/interfaces/discord/services/threads/discord-chat.service.ts @@ -1,12 +1,15 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Context, Options, SlashCommand, SlashCommandContext } from 'necord'; -import { DiscordTextDto } from '../../dto/discord-text.dto'; import { - CacheType, - ChatInputCommandInteraction, - EmbedBuilder, - userMention, -} from 'discord.js'; + Context, + MessageCommand, + MessageCommandContext, + Options, + SlashCommand, + SlashCommandContext, + TargetMessage, +} from 'necord'; +import { DiscordTextDto } from '../../dto/discord-text.dto'; +import { EmbedBuilder, Message, ThreadChannel, userMention } from 'discord.js'; import { DiscordMessageService } from './discord-message.service'; import { DiscordOnMessageService } from '../events/discord-on-message.service'; @@ -24,7 +27,7 @@ export class DiscordChatService { description: 'Opens a thread at this location and sets up a aiChat with with the chatbot.', }) - public async onChatCommand( + public async onSlashChatCommand( @Context() [interaction]: SlashCommandContext, @Options({ required: false }) startingText?: DiscordTextDto, ) { @@ -35,9 +38,12 @@ export class DiscordChatService { } this.logger.log( - `Creating thread with starting text:'${startingText.text}' in channel: name= ${interaction.channel.name}, id=${interaction.channel.id} `, + `Recieved '/chat' command with starting text:'${startingText.text}' in channel: name= ${interaction.channel.name}, id=${interaction.channel.id} `, + ); + const thread = await this._createNewThread( + startingText.text, + interaction, ); - const thread = await this._createNewThread(startingText, interaction); const firstThreadMessage = await thread.send( `Starting new chat with initial message:\n\n> ${startingText.text}`, @@ -45,6 +51,7 @@ export class DiscordChatService { await this._onMessageService.addActiveChat(firstThreadMessage); await this._messageService.respondToMessage( + firstThreadMessage, firstThreadMessage, interaction.user.id, true, @@ -54,36 +61,96 @@ export class DiscordChatService { } } - private async _createNewThread( - startingText: DiscordTextDto, - interaction: ChatInputCommandInteraction, + @MessageCommand({ + name: 'Open `/chat` thread', + }) + public async onMessageContextChatCommand( + @Context() [interaction]: MessageCommandContext, + @TargetMessage() message: Message, ) { - const maxThreadNameLength = 100; // Discord's maximum thread name length - let threadName = startingText.text; - if (threadName.length > maxThreadNameLength) { - threadName = threadName.substring(0, maxThreadNameLength); + await interaction.deferReply(); + + try { + const { humanInputText, attachmentText } = + await this._messageService.extractMessageContent(message); + + this.logger.log( + `Received 'message context menu' command for Message: ${message.id} in channel: name= ${interaction.channel.name}, id=${message.channel.id} `, + ); + const thread = await this._createNewThread( + humanInputText + attachmentText, + interaction, + ); + + const firstThreadMessage = await thread.send( + `Starting new chat with initial message:\n\n> ${ + humanInputText + attachmentText + }`, + ); + + await this._onMessageService.addActiveChat(firstThreadMessage); + await this._messageService.respondToMessage( + firstThreadMessage, + thread, + interaction.user.id, + true, + ); + } catch (error) { + this.logger.error(`Caught error: ${error}`); } - const threadTitleEmbed = new EmbedBuilder() - .setColor(0x0099ff) - .setAuthor({ - name: `Thread created by ${interaction.user.username}`, - iconURL: interaction.user.avatarURL(), - }) - .setTimestamp() - .addFields({ - name: '`skellybot` source code:', - value: 'https://github.com/freemocap/skellybot', + } + + private async _createNewThread(startingTextString: string, interaction) { + try { + const maxThreadNameLength = 100; // Discord's maximum thread name length + let threadName = startingTextString; + if (threadName.length > maxThreadNameLength) { + threadName = threadName.substring(0, maxThreadNameLength); + } + const threadTitleEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setAuthor({ + name: `Thread created by ${interaction.user.username}`, + iconURL: interaction.user.avatarURL(), + }) + .setTimestamp() + .addFields({ + name: '`skellybot` source code:', + value: 'https://github.com/freemocap/skellybot', + }); + + let threadAnchorMessage: Message; + + if (interaction.channel instanceof ThreadChannel) { + threadAnchorMessage = await interaction.channel.parent.send({ + content: `Thread Created for user: ${userMention( + interaction.user.id, + )} with starting text:\n\n> ${startingTextString}`, + embeds: [threadTitleEmbed], + }); + } else { + threadAnchorMessage = await interaction.channel.send({ + content: `Thread Created for user: ${userMention( + interaction.user.id, + )} with starting text:\n\n> ${startingTextString}`, + embeds: [threadTitleEmbed], + }); + } + + const thread = await threadAnchorMessage.startThread({ + name: threadName, }); - const threadCreationMessage = await interaction.editReply({ - content: `Thread Created for user: ${userMention( - interaction.user.id, - )} with starting text:\n\n> ${startingText.text}`, - embeds: [threadTitleEmbed], - attachments: [], - }); - return await threadCreationMessage.startThread({ - name: threadName, - }); + await interaction.editReply({ + content: `Created thread: \n\n> ${thread.name}: ${thread.url}`, + ephemeral: !(interaction.channel instanceof ThreadChannel), + }); + + return thread; + } catch (error) { + this.logger.error( + `Something went wrong during '_createNewThread()': Caught error: '${error}'`, + ); + } } } diff --git a/src/interfaces/discord/services/threads/discord-message.service.ts b/src/interfaces/discord/services/threads/discord-message.service.ts index 3417886..366e3ce 100644 --- a/src/interfaces/discord/services/threads/discord-message.service.ts +++ b/src/interfaces/discord/services/threads/discord-message.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { AttachmentBuilder, Message } from 'discord.js'; +import { AttachmentBuilder, Message, TextBasedChannel } from 'discord.js'; import { DiscordMongodbService } from '../discord-mongodb.service'; import { DiscordContextService } from './discord-context.service'; import { DiscordAttachmentService } from './discord-attachment.service'; @@ -7,6 +7,7 @@ import { OpenaiChatService } from '../../../../core/ai/openai/openai-chat.servic @Injectable() export class DiscordMessageService { + private readonly maxMessageLength = 2000 * 0.85; // discord max message length is 2000 characters (and * 0.85 to be safe) private readonly logger = new Logger(DiscordMessageService.name); constructor( private readonly _persistenceService: DiscordMongodbService, @@ -17,13 +18,17 @@ export class DiscordMessageService { public async respondToMessage( discordMessage: Message, + respondToChannelOrMessage: Message | TextBasedChannel, humanUserId: string, isFirstExchange: boolean = false, ) { - discordMessage.channel.sendTyping(); + await discordMessage.channel.sendTyping(); try { const { humanInputText, attachmentText } = - await this._extractMessageContent(discordMessage); + await this.extractMessageContent( + discordMessage, + respondToChannelOrMessage, + ); this.logger.log( `Received message with${ discordMessage.attachments.size > 0 ? ' ' : 'out ' @@ -36,18 +41,61 @@ export class DiscordMessageService { attachmentText, discordMessage, isFirstExchange, + respondToChannelOrMessage, ); } catch (error) { this.logger.error(`Error in respondToMessage: ${error}`); } } + public async sendChunkedMessage( + channelOrMessage: Message | TextBasedChannel, + responseText: string, + ) { + const messageParts = this._getChunks(responseText, this.maxMessageLength); + const replyMessages: Message[] = []; + + if (channelOrMessage instanceof Message) { + replyMessages.push(await channelOrMessage.reply(messageParts[0])); + } else { + replyMessages.push(await channelOrMessage.send(messageParts[0])); + } + + if (messageParts.length > 1) { + // Send the rest of the message parts as replies to the first message + for (const textChunk of messageParts.slice(1)) { + replyMessages.push( + await replyMessages[replyMessages.length - 1].reply(textChunk), + ); + } + + await this._sendFullResponseAsAttachment( + responseText, + channelOrMessage.id, + replyMessages[replyMessages.length - 1], + ); + } + return replyMessages; + } + + private _getChunks(text: string, maxChunkSize: number): string[] { + const chunks = []; + while (text.length) { + const chunkSize = Math.min(text.length, maxChunkSize); + const chunk = text.slice(0, chunkSize); + chunks.push(chunk); + text = text.slice(chunkSize); + } + return chunks; + } + private async _handleStream( humanUserId: string, inputMessageText: string, attachmentText: string, discordMessage: Message, isFirstExchange: boolean = false, + respondToChannelOrMessage: Message | TextBasedChannel, ) { try { const aiResponseStream = this._openaiChatService.getAiResponseStream( @@ -56,10 +104,15 @@ export class DiscordMessageService { ); const maxMessageLength = 2000 * 0.9; // discord max message length is 2000 characters (and *0.9 to be safe) - const replyMessages: Message[] = [ - await discordMessage.reply('Awaiting reply...'), - ]; - let currentReplyMessage = replyMessages[0]; + let currentReplyMessage: Message; + if (respondToChannelOrMessage instanceof Message) { + currentReplyMessage = + await respondToChannelOrMessage.reply('Awaiting reply...'); + } else { + currentReplyMessage = + await respondToChannelOrMessage.send('Awaiting reply...'); + } + const replyMessages: Message[] = [currentReplyMessage]; let replyWasSplitAcrossMessages = false; let currentReplyMessageText = ''; @@ -103,7 +156,7 @@ export class DiscordMessageService { if (replyWasSplitAcrossMessages) { await this._sendFullResponseAsAttachment( fullAiTextResponse, - discordMessage, + discordMessage.id, replyMessages[-1], ); } @@ -122,7 +175,10 @@ export class DiscordMessageService { } } - private async _extractMessageContent(discordMessage: Message) { + public async extractMessageContent( + discordMessage: Message, + respondToChannelOrMessage?: Message | TextBasedChannel, + ) { let humanInputText = discordMessage.content; let attachmentText = ''; if (discordMessage.attachments.size > 0) { @@ -137,35 +193,14 @@ export class DiscordMessageService { const attachmentResponse = await this._discordAttachmentService.handleAttachment(attachment); attachmentText += attachmentResponse.text; - if (attachmentResponse.type === 'transcript') { - const maxMessageLength = 1800; // Reduced to 1800 to account for "message X of N" text - const fullAttachmentText = attachmentResponse.text; - const attachmentTextLength = fullAttachmentText.length; - - if (attachmentTextLength > maxMessageLength) { - const numberOfMessages = Math.ceil( - attachmentTextLength / maxMessageLength, - ); - let replyMessage: Message; - for (let i = 0; i < numberOfMessages; i++) { - const start = i * maxMessageLength; - const end = start + maxMessageLength; - const chunk = fullAttachmentText.slice(start, end); - const chunkMsg = `> Message ${ - i + 1 - } of ${numberOfMessages}\n\n${chunk}`; - replyMessage = await discordMessage.reply(chunkMsg); - } - if (replyMessage) { - await this._sendFullResponseAsAttachment( - attachmentResponse.text, - discordMessage, - replyMessage, - ); - } - } else { - await discordMessage.reply(fullAttachmentText); - } + if ( + respondToChannelOrMessage && + attachmentResponse.type === 'transcript' + ) { + await this.sendChunkedMessage( + respondToChannelOrMessage, + attachmentText, + ); } attachmentText += 'END TEXT FROM ATTACHMENTS'; } @@ -175,17 +210,18 @@ export class DiscordMessageService { private async _sendFullResponseAsAttachment( fullAiResponse: string, - discordMessage: Message, + discordMessageId: string, replyMessage: Message, ) { const attachment = new AttachmentBuilder(Buffer.from(fullAiResponse), { - name: `full_response_to_discordMessageId_${discordMessage.id}.md`, + name: `full_response_to_discordMessageId_${discordMessageId}.md`, description: 'The full Ai response to message ID:${discordMessage.id}, ' + 'which was split across multiple messages so is being sent as an' + ' attachment for convenience.', }); await replyMessage.edit({ + content: replyMessage.content, files: [attachment], }); } diff --git a/src/main/main.module.ts b/src/main/main.module.ts index 47f5fa7..782b500 100644 --- a/src/main/main.module.ts +++ b/src/main/main.module.ts @@ -3,7 +3,6 @@ import { SlackModule } from '../interfaces/slack/slack.module'; import { MainController } from './main.controller'; import { ConfigModule } from '@nestjs/config'; import { DiscordModule } from '../interfaces/discord/discord.module'; -import { DatabaseModule } from '../core/database/database.module'; @Module({ imports: [