diff --git a/app/ThreadSummarizerApp.ts b/app/ThreadSummarizerApp.ts index b810d5d..0c5c617 100644 --- a/app/ThreadSummarizerApp.ts +++ b/app/ThreadSummarizerApp.ts @@ -1,12 +1,24 @@ import { IAppAccessors, IConfigurationExtend, + IHttp, ILogger, + IModify, + IPersistence, + IRead, } from '@rocket.chat/apps-engine/definition/accessors'; import { App } from '@rocket.chat/apps-engine/definition/App'; import { IAppInfo } from '@rocket.chat/apps-engine/definition/metadata'; import { SummarizeCommand } from './commands/SummarizeCommand'; import { settings } from './settings/settings'; +import { ActionButton } from './enum/ActionButton'; +import { UIActionButtonContext, IUIActionButtonDescriptor } from '@rocket.chat/apps-engine/definition/ui'; +import { IUIKitResponse, UIKitActionButtonInteractionContext, UIKitBlockInteractionContext, UIKitViewCloseInteractionContext, UIKitViewSubmitInteractionContext } from '@rocket.chat/apps-engine/definition/uikit'; +import { ActionButtonHandler } from './handlers/ExecuteActionButtonHandler'; +import { ExecuteBlockActionHandler } from './handlers/ExecuteBlockActionHandler'; +import { ExecuteViewClosedHandler } from './handlers/ExecuteViewCloseHandler'; +import { ExecuteViewSubmitHandler } from './handlers/ExecuteViewSubmitHandler'; + export class ThreadSummarizerApp extends App { constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) { @@ -14,6 +26,15 @@ export class ThreadSummarizerApp extends App { } public async extendConfiguration(configuration: IConfigurationExtend) { + + const summarizeMessageButton: IUIActionButtonDescriptor = { + actionId: ActionButton.SUMMARIZE_MESSAGES_ACTION, + labelI18n: ActionButton.SUMMARIZE_MESSAGES_LABEL, + context: UIActionButtonContext.MESSAGE_BOX_ACTION, + } + + configuration.ui.registerButton(summarizeMessageButton); + await Promise.all([ ...settings.map((setting) => configuration.settings.provideSetting(setting) @@ -23,4 +44,77 @@ export class ThreadSummarizerApp extends App { ), ]); } + + public async executeActionButtonHandler( + context: UIKitActionButtonInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + const handler = new ActionButtonHandler().executor( + this, + context, + read, + http, + persistence, + modify, + ) + + return await handler + } + + public async executeBlockActionHandler( + context: UIKitBlockInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + const {threadId }= context.getInteractionData(); + this.getLogger().debug(threadId, "threaid") + const handler = new ExecuteBlockActionHandler( + this, + read, + http, + persistence, + modify, + context, + ); + + return await handler.handleActions(context); + } + + public async executeViewClosedHandler( + context: UIKitViewCloseInteractionContext, + persistence: IPersistence, + + ): Promise { + const handler = new ExecuteViewClosedHandler( + context, + persistence + ); + + return await handler.handleActions(); + } + + public async executeViewSubmitHandler( + context: UIKitViewSubmitInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ) { + const handler = new ExecuteViewSubmitHandler( + this, + read, + http, + persistence, + modify, + context, + ); + + return await handler.handleActions(context); + } + } diff --git a/app/app.json b/app/app.json index 165b856..c38fcea 100644 --- a/app/app.json +++ b/app/app.json @@ -46,6 +46,12 @@ }, { "name": "upload.read" + }, + { + "name": "ui.registerButtons" + }, + { + "name": "ui.interact" } ] } \ No newline at end of file diff --git a/app/commands/SummarizeCommand.ts b/app/commands/SummarizeCommand.ts index 092af66..58ecb0c 100644 --- a/app/commands/SummarizeCommand.ts +++ b/app/commands/SummarizeCommand.ts @@ -3,30 +3,22 @@ import { IModify, IRead, } from '@rocket.chat/apps-engine/definition/accessors'; -import { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; import { ISlashCommand, SlashCommandContext, } from '@rocket.chat/apps-engine/definition/slashcommands'; -import { IUser } from '@rocket.chat/apps-engine/definition/users'; import { notifyMessage } from '../helpers/notifyMessage'; import { createTextCompletion } from '../helpers/createTextCompletion'; import { - createAssignedTasksPrompt, - createFileSummaryPrompt, - createFollowUpQuestionsPrompt, - createParticipantsSummaryPrompt, - createPromptInjectionProtectionPrompt, - createSummaryPrompt, - createSummaryPromptByTopics, + createUserHelpPrompt, } from '../constants/prompts'; -import { App } from '@rocket.chat/apps-engine/definition/App'; -import { IMessageRaw } from '@rocket.chat/apps-engine/definition/messages'; import { FREQUENTLY_ASKED_QUESTIONS, WELCOME_MESSAGE, } from '../constants/dialogue'; +import { getStartDate, handleSummaryGeneration } from '../helpers/summarizeHelper'; +import { ThreadSummarizerApp } from '../ThreadSummarizerApp'; export class SummarizeCommand implements ISlashCommand { public command = 'chat-summary'; @@ -35,9 +27,9 @@ export class SummarizeCommand implements ISlashCommand { public i18nDescription = 'Generates a summary of recent messages. Use "today", "week", or "unread" to filter the messages'; public providesPreview = false; - private readonly app: App; + private readonly app: ThreadSummarizerApp; - constructor(app: App) { + constructor(app: ThreadSummarizerApp) { this.app = app; } @@ -54,57 +46,11 @@ export class SummarizeCommand implements ISlashCommand { const command = context.getArguments(); const [subcommand] = context.getArguments(); const filter = subcommand ? subcommand.toLowerCase() : ''; + const now = new Date(); + const startDate = getStartDate(filter, now); + const anyMatchedUsername = false - let unreadCount: number | undefined; - let startDate: Date | undefined; - let usernames: string[] | undefined; - const anyMatchedUsername = false; - const now = new Date(); - - if (!subcommand) { - startDate = undefined; - } else { - switch (filter) { - case 'today': - startDate = new Date( - now.getFullYear(), - now.getMonth(), - now.getDate(), - 0, - 0, - 0, - 0 - ); - break; - case 'week': - startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - case 'unread': - unreadCount = await read - .getUserReader() - .getUserUnreadMessageCount(user.id); - break; - case 'help': - break; - default: - usernames = command.map((name) => name.replace(/^@/, '')); - } - } - - const addOns = await this.app - .getAccessors() - .environmentReader.getSettings() - .getValueById('add-ons'); - const xAuthToken = await this.app - .getAccessors() - .environmentReader.getSettings() - .getValueById('x-auth-token'); - const xUserId = await this.app - .getAccessors() - .environmentReader.getSettings() - .getValueById('x-user-id'); - - let helpResponse: string; + let helpResponse: string; if (filter === 'help') { if (subcommand === command.join(' ')) { await notifyMessage(room, read, user, WELCOME_MESSAGE, threadId); @@ -138,352 +84,15 @@ export class SummarizeCommand implements ISlashCommand { return; } - let messages: string; - if (!threadId) { - messages = await this.getRoomMessages( - room, - read, - user, - http, - addOns, - xAuthToken, - xUserId, - startDate, - unreadCount, - usernames, - anyMatchedUsername - ); - } else { - messages = await this.getThreadMessages( - room, - read, - user, - http, - threadId, - addOns, - xAuthToken, - xUserId, - startDate, - unreadCount, - usernames, - anyMatchedUsername - ); - } - - if (!messages || messages.trim().length === 0) { - await notifyMessage( - room, - read, - user, - 'There are no messages to summarize in this channel.', - threadId - ); - return; - } - - await notifyMessage(room, read, user, messages, threadId); - - // const promptInjectionProtectionPrompt = - // createPromptInjectionProtectionPrompt(messages); - // const isPromptInjection = await createTextCompletion( - // this.app, - // room, - // read, - // user, - // http, - // promptInjectionProtectionPrompt, - // threadId - // ); - // if (isPromptInjection) { - // await notifyMessage( - // room, - // read, - // user, - // 'Prompt injection detected! You are not allowed to summarize messages that have potential attack to the AI model', - // threadId - // ); - // throw new Error('Prompt injection detected'); - // } - - let summary: string; - if (!threadId) { - const prompt = createSummaryPromptByTopics(messages); - summary = await createTextCompletion( - this.app, - room, - read, - user, - http, - prompt, - threadId - ); - } else { - const prompt = createSummaryPrompt(messages); - summary = await createTextCompletion( - this.app, - room, - read, - user, - http, - prompt, - threadId - ); - } - await notifyMessage(room, read, user, summary, threadId); + if(!subcommand){ + await handleSummaryGeneration(this.app, read, http, room, user, threadId, startDate, undefined, undefined, anyMatchedUsername ); + } else{ + const usernames:string[] | undefined = (['today', 'week', 'unread'].includes(filter) && subcommand) ? undefined : command.map(name => name.replace(/^@/, '')); - if (addOns.includes('assigned-tasks')) { - const assignedTasksPrompt = createAssignedTasksPrompt(messages); - const assignedTasks = await createTextCompletion( - this.app, - room, - read, - user, - http, - assignedTasksPrompt, - threadId - ); - await notifyMessage(room, read, user, assignedTasks, threadId); - } + const unreadCount = filter === 'unread' ? await read.getUserReader().getUserUnreadMessageCount(user.id) : undefined; - if (addOns.includes('follow-up-questions')) { - const followUpQuestionsPrompt = createFollowUpQuestionsPrompt(messages); - const followUpQuestions = await createTextCompletion( - this.app, - room, - read, - user, - http, - followUpQuestionsPrompt, - threadId - ); - await notifyMessage(room, read, user, followUpQuestions, threadId); - } + await handleSummaryGeneration(this.app, read, http, room, user, threadId, startDate, unreadCount, usernames, anyMatchedUsername); + } + } - if (addOns.includes('participants-summary')) { - const participantsSummaryPrompt = - createParticipantsSummaryPrompt(messages); - const participantsSummary = await createTextCompletion( - this.app, - room, - read, - user, - http, - participantsSummaryPrompt, - threadId - ); - await notifyMessage(room, read, user, participantsSummary, threadId); - } - } - - private async getFileSummary( - fileId: string, - read: IRead, - room: IRoom, - user: IUser, - http: IHttp, - xAuthToken: string, - xUserId: string, - threadId?: string - ): Promise { - const uploadReader = read.getUploadReader(); - const file = await uploadReader.getById(fileId); - if (file && file.type === 'text/plain') { - const response = await fetch(file.url, { - method: 'GET', - headers: { - 'X-Auth-Token': xAuthToken, - 'X-User-Id': xUserId, - }, - }); - const fileContent = await response.text(); - const fileSummaryPrompt = createFileSummaryPrompt(fileContent); - return createTextCompletion( - this.app, - room, - read, - user, - http, - fileSummaryPrompt, - threadId - ); - } - return 'File type is not supported'; - } - - private async getRoomMessages( - room: IRoom, - read: IRead, - user: IUser, - http: IHttp, - addOns: string[], - xAuthToken: string, - xUserId: string, - startDate?: Date, - unreadCount?: number, - usernames?: string[], - anyMatchedUsername?: boolean - ): Promise { - const messages: IMessageRaw[] = await read - .getRoomReader() - .getMessages(room.id, { - limit: Math.min(unreadCount || 100, 100), - sort: { createdAt: 'asc' }, - }); - - let filteredMessages = messages; - - if (usernames) { - filteredMessages = messages.filter((message) => { - const isMatched = usernames.includes(message.sender.username); - if (isMatched) { - anyMatchedUsername = true; - } - return isMatched; - }); - - if (!anyMatchedUsername) { - return `Please enter a valid command! - You can try: - \t 1. /chat-summary - \t 2. /chat-summary today - \t 3. /chat-summary week - \t 4. /chat-summary unread - \t 5. /chat-summary @ or /chat-summary @ @ - \t 6. /chat-summary help - \t 7. /chat-summary help `; - } - } - - if (startDate) { - const today = new Date(); - filteredMessages = messages.filter((message) => { - const createdAt = new Date(message.createdAt); - return createdAt >= startDate && createdAt <= today; - }); - } - - const messageTexts: string[] = []; - for (const message of filteredMessages) { - if (message.text) { - messageTexts.push( - `Message at ${message.createdAt}\n${message.sender.name}: ${message.text}\n` - ); - } - if (addOns.includes('file-summary') && message.file) { - if (!xAuthToken || !xUserId) { - await notifyMessage( - room, - read, - user, - 'Personal Access Token and User ID must be filled in settings to enable file summary add-on' - ); - continue; - } - const fileSummary = await this.getFileSummary( - message.file._id, - read, - room, - user, - http, - xAuthToken, - xUserId - ); - messageTexts.push('File Summary: ' + fileSummary); - } - } - return messageTexts.join('\n'); - } - - private async getThreadMessages( - room: IRoom, - read: IRead, - user: IUser, - http: IHttp, - threadId: string, - addOns: string[], - xAuthToken: string, - xUserId: string, - startDate?: Date, - unreadCount?: number, - usernames?: string[], - anyMatchedUsername?: boolean - ): Promise { - const threadReader = read.getThreadReader(); - const thread = await threadReader.getThreadById(threadId); - if (!thread) { - await notifyMessage(room, read, user, 'Thread not found'); - throw new Error('Thread not found'); - } - - let filteredMessages = thread; - if (usernames) { - filteredMessages = thread.filter((message) => { - const isMatched = usernames.includes(message.sender.username); - if (isMatched) { - anyMatchedUsername = true; - } - return isMatched; - }); - - if (!anyMatchedUsername) { - return `Please enter a valid command! - You can try: - \t 1. /chat-summary - \t 2. /chat-summary today - \t 3. /chat-summary week - \t 4. /chat-summary unread - \t 5. /chat-summary `; - } - } - if (startDate) { - const today = new Date(); - filteredMessages = thread.filter((message) => { - if (!message.createdAt) return false; - const createdAt = new Date(message.createdAt); - return createdAt >= startDate && createdAt <= today; - }); - } - - if (unreadCount && unreadCount > 0) { - if (unreadCount > 100) { - unreadCount = 100; - } - filteredMessages = filteredMessages.slice(-unreadCount); - } - - const messageTexts: string[] = []; - for (const message of filteredMessages) { - if (message.text) { - messageTexts.push(`${message.sender.name}: ${message.text}`); - } - if (addOns.includes('file-summary') && message.file) { - if (!xAuthToken || !xUserId) { - await notifyMessage( - room, - read, - user, - 'Personal Access Token and User ID must be filled in settings to enable file summary add-on' - ); - continue; - } - const fileSummary = await this.getFileSummary( - message.file._id, - read, - room, - user, - http, - xAuthToken, - xUserId, - threadId - ); - messageTexts.push('File Summary: ' + fileSummary); - } - } - - // threadReader repeats the first message once, so here we remove it - if (messageTexts.length > 0) { - messageTexts.shift(); - } - return messageTexts.join('\n'); - } } diff --git a/app/enum/ActionButton.ts b/app/enum/ActionButton.ts new file mode 100644 index 0000000..9d915ac --- /dev/null +++ b/app/enum/ActionButton.ts @@ -0,0 +1,4 @@ +export enum ActionButton { + SUMMARIZE_MESSAGES_ACTION = 'summarize_messages', + SUMMARIZE_MESSAGES_LABEL = 'Summarize_messages', +} \ No newline at end of file diff --git a/app/enum/keys.ts b/app/enum/keys.ts new file mode 100644 index 0000000..6a58f82 --- /dev/null +++ b/app/enum/keys.ts @@ -0,0 +1,2 @@ +export const ROOM_ID_KEY = 'room-id-key'; +export const THREAD_ID_KEY = 'thread-id-key'; \ No newline at end of file diff --git a/app/enum/modal/summarizeModal.ts b/app/enum/modal/summarizeModal.ts new file mode 100644 index 0000000..b253df2 --- /dev/null +++ b/app/enum/modal/summarizeModal.ts @@ -0,0 +1,11 @@ +export enum SummarizeModalEnum { + FILTER_SUMMARIES_DROPDOWN_BLOCK_ID = 'filter-summaries-block-id', + FILTER_SUMMARIES_DROPDOWN_ACTION_ID = 'filter-summaries-action-id', + CLOSE_ACTION_ID = 'close-action-id', + CLOSE_BLOCK_ID = 'close-block-id', + SUBMIT_ACTION_ID = 'submit-action-id', + SUBMIT_BLOCK_ID = 'submit-block-id', + USER_LISTS_BLOCK_ID = 'user-list-block-id', + USER_LISTS_ACTION_ID = 'user-list-action-id', + VIEW_ID = 'summarize-view-id' +} \ No newline at end of file diff --git a/app/handlers/ExecuteActionButtonHandler.ts b/app/handlers/ExecuteActionButtonHandler.ts new file mode 100644 index 0000000..ec21674 --- /dev/null +++ b/app/handlers/ExecuteActionButtonHandler.ts @@ -0,0 +1,53 @@ +import { + IHttp, + IModify, + IPersistence, + IRead, +} from "@rocket.chat/apps-engine/definition/accessors"; +import { + IUIKitResponse, + UIKitActionButtonInteractionContext, +} from "@rocket.chat/apps-engine/definition/uikit"; +import { ThreadSummarizerApp } from "../ThreadSummarizerApp"; +import { summarizeModal } from "../modal/summarizeModal"; +import { clearData, storeData } from "../lib/dataStore"; +import { ROOM_ID_KEY, THREAD_ID_KEY } from "../enum/keys"; + +export class ActionButtonHandler { + public async executor( + app: ThreadSummarizerApp, + context: UIKitActionButtonInteractionContext, + read: IRead, + http: IHttp, + persistence: IPersistence, + modify: IModify, + ): Promise { + const { triggerId, user, room, threadId, } = context.getInteractionData(); + + await clearData(persistence, user.id, THREAD_ID_KEY); + await clearData(persistence, user.id, ROOM_ID_KEY); + + const modal = await summarizeModal( + app, + read, + user, + ); + if(user.id){ + const roomId = room.id; + await storeData(persistence, user.id, ROOM_ID_KEY, {roomId}); + if (threadId) { + await storeData(persistence, user.id, THREAD_ID_KEY, { threadId }); + } + } + + + if (triggerId) { + await modify + .getUiController() + .openSurfaceView(modal, { triggerId: triggerId }, user); + } + + + return context.getInteractionResponder().successResponse(); + } +} diff --git a/app/handlers/ExecuteBlockActionHandler.ts b/app/handlers/ExecuteBlockActionHandler.ts new file mode 100644 index 0000000..5e7acef --- /dev/null +++ b/app/handlers/ExecuteBlockActionHandler.ts @@ -0,0 +1,54 @@ +import { + IUIKitResponse, + UIKitBlockInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; + + +import { SummarizeModalEnum } from '../enum/modal/summarizeModal'; +import { ThreadSummarizerApp } from '../ThreadSummarizerApp'; +import { summarizeModal } from '../modal/summarizeModal'; + + +export class ExecuteBlockActionHandler { + private context: UIKitBlockInteractionContext; + constructor( + protected readonly app: ThreadSummarizerApp, + protected readonly read: IRead, + protected readonly http: IHttp, + protected readonly persistence: IPersistence, + protected readonly modify: IModify, + context: UIKitBlockInteractionContext, + ) { + this.context = context; + } + + public async handleActions(context: UIKitBlockInteractionContext): Promise { + const { + actionId, + user, + value, + } = context.getInteractionData(); + + + + if(actionId === SummarizeModalEnum.FILTER_SUMMARIES_DROPDOWN_ACTION_ID) { + const updatedModal = await summarizeModal( + this.app, + this.read, + user, + value + ) + + return this.context + .getInteractionResponder() + .updateModalViewResponse(updatedModal) + } + return this.context.getInteractionResponder().successResponse(); + } +} diff --git a/app/handlers/ExecuteViewCloseHandler.ts b/app/handlers/ExecuteViewCloseHandler.ts new file mode 100644 index 0000000..96978e7 --- /dev/null +++ b/app/handlers/ExecuteViewCloseHandler.ts @@ -0,0 +1,24 @@ +import { + IUIKitResponse, + UIKitViewCloseInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; + +import { IPersistence } from '@rocket.chat/apps-engine/definition/accessors'; + +export class ExecuteViewClosedHandler { + private context: UIKitViewCloseInteractionContext; + private persistence: IPersistence; + constructor( + context: UIKitViewCloseInteractionContext, + persistence: IPersistence + ) { + this.context = context; + this.persistence = persistence + } + + public async handleActions(): Promise { + + return this.context.getInteractionResponder().successResponse(); + + } +} diff --git a/app/handlers/ExecuteViewSubmitHandler.ts b/app/handlers/ExecuteViewSubmitHandler.ts new file mode 100644 index 0000000..d33e44a --- /dev/null +++ b/app/handlers/ExecuteViewSubmitHandler.ts @@ -0,0 +1,66 @@ +import { + IUIKitResponse, + UIKitViewSubmitInteractionContext, +} from '@rocket.chat/apps-engine/definition/uikit'; +import { + IHttp, + IModify, + IPersistence, + IRead, +} from '@rocket.chat/apps-engine/definition/accessors'; +import { ThreadSummarizerApp } from '../ThreadSummarizerApp'; +import { SummarizeModalEnum } from '../enum/modal/summarizeModal'; +import { IRoom } from '@rocket.chat/apps-engine/definition/rooms'; +import { ROOM_ID_KEY, THREAD_ID_KEY } from '../enum/keys'; +import { getData } from '../lib/dataStore'; +import {getStartDate, handleSummaryGeneration} from '../helpers/summarizeHelper'; + +export class ExecuteViewSubmitHandler { + private context: UIKitViewSubmitInteractionContext; + + constructor( + protected readonly app: ThreadSummarizerApp, + protected readonly read: IRead, + protected readonly http: IHttp, + protected readonly persistence: IPersistence, + protected readonly modify: IModify, + context: UIKitViewSubmitInteractionContext, + ) { + this.context = context; + } + + public async handleActions(context: UIKitViewSubmitInteractionContext): Promise { + const { view, user} = context.getInteractionData(); + const{ roomId } = await getData( + this.read.getPersistenceReader(), + user.id, + ROOM_ID_KEY, + ); + + const { threadId } = await getData( + this.read.getPersistenceReader(), + user.id, + THREAD_ID_KEY, + ) + + if (!roomId) return this.context.getInteractionResponder().successResponse(); + + const room = (await this.read.getRoomReader().getById(roomId)) as IRoom; + const viewId = view.id.split('---')[0].trim(); + + if (viewId !== SummarizeModalEnum.VIEW_ID) return this.context.getInteractionResponder().successResponse(); + + const filter = view.state?.[SummarizeModalEnum.FILTER_SUMMARIES_DROPDOWN_BLOCK_ID]?.[SummarizeModalEnum.FILTER_SUMMARIES_DROPDOWN_ACTION_ID]; + const now = new Date(); + + const anyMatchedUsername = false; + const startDate = getStartDate(filter, now); + const unreadCount = filter === 'unread' ? await this.read.getUserReader().getUserUnreadMessageCount(user.id) : undefined; + const usernames: string[] | undefined = filter === 'users' ? view.state?.[SummarizeModalEnum.USER_LISTS_BLOCK_ID]?.[SummarizeModalEnum.USER_LISTS_ACTION_ID] : undefined; + + await handleSummaryGeneration(this.app, this.read, this.http, room, user, threadId, startDate, unreadCount,usernames, anyMatchedUsername); + + return this.context.getInteractionResponder().successResponse(); + } + +} diff --git a/app/helpers/createTextCompletion.ts b/app/helpers/createTextCompletion.ts index 8d74916..71fd2f5 100644 --- a/app/helpers/createTextCompletion.ts +++ b/app/helpers/createTextCompletion.ts @@ -70,7 +70,7 @@ export async function createTextCompletion( ); if (!response || !response.data) { - app.getLogger().log('No response data received from AI.'); + app.getLogger().log('No response data received from AI.'); return 'Something went wrong. Please try again later.'; } diff --git a/app/helpers/summarizeHelper.ts b/app/helpers/summarizeHelper.ts new file mode 100644 index 0000000..8583d1f --- /dev/null +++ b/app/helpers/summarizeHelper.ts @@ -0,0 +1,344 @@ +import { IRoom } from "@rocket.chat/apps-engine/definition/rooms"; +import { IUser } from "@rocket.chat/apps-engine/definition/users"; +import { notifyMessage } from "./notifyMessage"; +import { createAssignedTasksPrompt, createFileSummaryPrompt, createFollowUpQuestionsPrompt, createParticipantsSummaryPrompt, createSummaryPrompt, createSummaryPromptByTopics } from "../constants/prompts"; +import { createTextCompletion } from "./createTextCompletion"; +import { ThreadSummarizerApp } from "../ThreadSummarizerApp"; +import { IHttp, IRead } from "@rocket.chat/apps-engine/definition/accessors"; +import { IMessageRaw } from "@rocket.chat/apps-engine/definition/messages"; + +export async function handleSummaryGeneration( + app: ThreadSummarizerApp, + read: IRead, + http: IHttp, + room: IRoom, + user: IUser, + threadId: string | undefined, + startDate?: Date, + unreadCount?: number, + usernames?: string[] | undefined, + anyMatchedUsername?: boolean, +): Promise { + const addOns = await app.getAccessors().environmentReader.getSettings().getValueById('add-ons'); + const xAuthToken = await app.getAccessors().environmentReader.getSettings().getValueById('x-auth-token'); + const xUserId = await app.getAccessors().environmentReader.getSettings().getValueById('x-user-id'); + + + let messages: string; + if(!threadId) { + messages = await getRoomMessages( + app, + room, + read, + user, + http, + addOns, + xAuthToken, + xUserId, + startDate, + unreadCount, + usernames, + anyMatchedUsername + ); + } else { + messages = await getThreadMessages( + app, + room, + read, + user, + http, + threadId, + addOns, + xAuthToken, + xUserId, + startDate, + unreadCount, + usernames, + anyMatchedUsername + ); + } + + if (!messages?.trim()) { + await notifyMessage(room, read, user, 'There are no messages to summarize in this channel.', threadId); + return; + } + + await notifyMessage(room, read, user, messages, threadId); + + const summary = await generateSummary(app, read, http, messages, threadId, room, user); + await notifyMessage(room, read, user, summary, threadId); + + await handleAddons(app, read, http,messages, room, user, threadId, addOns); +} + +async function generateSummary( + app: ThreadSummarizerApp, + read: IRead, + http: IHttp, + messages: string, + threadId: string | undefined, + room: IRoom, + user: IUser, + ) { + const prompt = threadId ? createSummaryPrompt(messages) : createSummaryPromptByTopics(messages); + return createTextCompletion(app, room, read, user, http, prompt, threadId); +} + +async function handleAddons( + app: ThreadSummarizerApp, + read: IRead, + http: IHttp, + messages: string, + room: IRoom, + user: IUser, + threadId: string | undefined, + addOns: string[]) { + const addonHandlers = { + 'assigned-tasks': async () => { + const prompt = createAssignedTasksPrompt(messages); + const result = await createTextCompletion(app, room, read, user, http, prompt, threadId); + await notifyMessage(room, read, user, result, threadId); + }, + 'follow-up-questions': async () => { + const prompt = createFollowUpQuestionsPrompt(messages); + const result = await createTextCompletion(app, room, read, user, http, prompt, threadId); + await notifyMessage(room, read, user, result, threadId); + }, + 'participants-summary': async () => { + const prompt = createParticipantsSummaryPrompt(messages); + const result = await createTextCompletion(app, room, read, user, http, prompt, threadId); + await notifyMessage(room, read, user, result, threadId); + } + }; + + for (const addon of addOns) { + await addonHandlers[addon as keyof typeof addonHandlers]?.(); + } + } + + + export async function getFileSummary( + app: ThreadSummarizerApp, + fileId: string, + read: IRead, + room: IRoom, + user: IUser, + http: IHttp, + xAuthToken: string, + xUserId: string, + threadId?: string +): Promise { + const uploadReader = read.getUploadReader(); + const file = await uploadReader.getById(fileId); + if (file && file.type === 'text/plain') { + const response = await fetch(file.url, { + method: 'GET', + headers: { + 'X-Auth-Token': xAuthToken, + 'X-User-Id': xUserId, + }, + }); + const fileContent = await response.text(); + const fileSummaryPrompt = createFileSummaryPrompt(fileContent); + return createTextCompletion( + app, + room, + read, + user, + http, + fileSummaryPrompt, + threadId + ); + } + return 'File type is not supported'; +} + +export async function getRoomMessages( + app: ThreadSummarizerApp, + room: IRoom, + read: IRead, + user: IUser, + http: IHttp, + addOns: string[], + xAuthToken: string, + xUserId: string, + startDate?: Date, + unreadCount?: number, + usernames?: string[], + anyMatchedUsername?: boolean +): Promise { + const messages: IMessageRaw[] = await read + .getRoomReader() + .getMessages(room.id, { + limit: Math.min(unreadCount || 100, 100), + sort: { createdAt: 'asc' }, + }); + + let filteredMessages = messages; + + if (usernames) { + filteredMessages = messages.filter((message) => { + const isMatched = usernames.includes(message.sender.username); + if (isMatched) { + anyMatchedUsername = true; + } + return isMatched; + }); + + if (!anyMatchedUsername) { + return `Please enter a valid command! + You can try:: + \t 1. /chat-summary + \t 2. /chat-summary today + \t 3. /chat-summary week + \t 4. /chat-summary unread + \t 5. /chat-summary @ or /chat-summary @ @ + \t 6. /chat-summary help + \t 7. /chat-summary help `; + } + } + + if (startDate) { + const today = new Date(); + filteredMessages = messages.filter((message) => { + const createdAt = new Date(message.createdAt); + return createdAt >= startDate && createdAt <= today; + }); + } + + const messageTexts: string[] = []; + for (const message of filteredMessages) { + if (message.text) { + messageTexts.push( + `Message at ${message.createdAt}\n${message.sender.name}: ${message.text}\n` + ); + } + if (addOns.includes('file-summary') && message.file) { + if (!xAuthToken || !xUserId) { + await notifyMessage( + room, + read, + user, + 'Personal Access Token and User ID must be filled in settings to enable file summary add-on' + ); + continue; + } + const fileSummary = await getFileSummary( + app, + message.file._id, + read, + room, + user, + http, + xAuthToken, + xUserId + ); + messageTexts.push('File Summary: ' + fileSummary); + } + } + return messageTexts.join('\n'); +} + +export async function getThreadMessages( + app: ThreadSummarizerApp, + room: IRoom, + read: IRead, + user: IUser, + http: IHttp, + threadId: string, + addOns: string[], + xAuthToken: string, + xUserId: string, + startDate?: Date, + unreadCount?: number, + usernames?: string[], + anyMatchedUsername?: boolean +): Promise { + const threadReader = read.getThreadReader(); + const thread = await threadReader.getThreadById(threadId); + if (!thread) { + await notifyMessage(room, read, user, 'Thread not found'); + throw new Error('Thread not found'); + } + + let filteredMessages = thread; + if (usernames) { + filteredMessages = thread.filter((message) => { + const isMatched = usernames.includes(message.sender.username); + if (isMatched) { + anyMatchedUsername = true; + } + return isMatched; + }); + + if (!anyMatchedUsername) { + return `Please enter a valid command! + You can try: + \t 1. /chat-summary + \t 2. /chat-summary today + \t 3. /chat-summary week + \t 4. /chat-summary unread + \t 5. /chat-summary `; + } + } + if (startDate) { + const today = new Date(); + filteredMessages = thread.filter((message) => { + if (!message.createdAt) return false; + const createdAt = new Date(message.createdAt); + return createdAt >= startDate && createdAt <= today; + }); + } + + if (unreadCount && unreadCount > 0) { + if (unreadCount > 100) { + unreadCount = 100; + } + filteredMessages = filteredMessages.slice(-unreadCount); + } + + const messageTexts: string[] = []; + for (const message of filteredMessages) { + if (message.text) { + messageTexts.push(`${message.sender.name}: ${message.text}`); + } + if (addOns.includes('file-summary') && message.file) { + if (!xAuthToken || !xUserId) { + await notifyMessage( + room, + read, + user, + 'Personal Access Token and User ID must be filled in settings to enable file summary add-on' + ); + continue; + } + const fileSummary = await getFileSummary( + app, + message.file._id, + read, + room, + user, + http, + xAuthToken, + xUserId, + threadId + ); + messageTexts.push('File Summary: ' + fileSummary); + } + } + + // threadReader repeats the first message once, so here we remove it + if (messageTexts.length > 0) { + messageTexts.shift(); + } + return messageTexts.join('\n'); +} + +export function getStartDate(filter: string, now: Date) { + switch (filter) { + case 'today': return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + case 'week': return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + default: return undefined; + } +} + diff --git a/app/i18n/en.json b/app/i18n/en.json new file mode 100644 index 0000000..f20d9a6 --- /dev/null +++ b/app/i18n/en.json @@ -0,0 +1,3 @@ +{ + "Summarize_messages": "Summarize messages" +} \ No newline at end of file diff --git a/app/lib/BlockBuilder.ts b/app/lib/BlockBuilder.ts new file mode 100644 index 0000000..990aafd --- /dev/null +++ b/app/lib/BlockBuilder.ts @@ -0,0 +1,42 @@ +import { + LayoutBlockType, + TextObjectType, + InputBlock, + DividerBlock, +} from '@rocket.chat/ui-kit'; +import { IBlockBuilder } from '../ui-kit/Block/IBlockBuilder'; +import { InputBlockParam } from '../ui-kit/Block/IInputBlock'; + +export class BlockBuilder implements IBlockBuilder { + constructor(private readonly appId: string) {} + + public createInputBlock(param: InputBlockParam): InputBlock { + const { text, element, blockId, hint, optional } = param; + + const inputBlock: InputBlock = { + type: LayoutBlockType.INPUT, + label: { + type: TextObjectType.PLAIN_TEXT, + text, + }, + appId: this.appId, + element, + hint, + optional, + blockId, + }; + + return inputBlock; + } + + public createDividerBlock(blockId?: string | undefined): DividerBlock { + const dividerBlock: DividerBlock = { + type: LayoutBlockType.DIVIDER, + appId: this.appId, + blockId, + }; + + return dividerBlock; + } + +} diff --git a/app/lib/ElementBuilder.ts b/app/lib/ElementBuilder.ts new file mode 100644 index 0000000..9768aa8 --- /dev/null +++ b/app/lib/ElementBuilder.ts @@ -0,0 +1,155 @@ +import { ButtonParam } from '../ui-kit/Element/IButtonElement'; +import { + IElementBuilder, + ElementInteractionParam, +} from '../ui-kit/Element/IElementBuilder'; +import { + ButtonElement, + BlockElementType, + TextObjectType, + Option, + StaticSelectElement, + MultiStaticSelectElement +} from '@rocket.chat/ui-kit'; +import { + StaticSelectElementParam, + StaticSelectOptionsParam, +} from '../ui-kit/Element/IStaticSelectElement'; +import { MultiStaticSelectElementParam, MultiStaticSelectOptionsParam } from '../ui-kit/Element/IMultiStaticSelectElement'; + +export class ElementBuilder implements IElementBuilder { + constructor(private readonly appId: string) {} + public addButton( + param: ButtonParam, + interaction: ElementInteractionParam, + ): ButtonElement { + const { text, url, value, style } = param; + const { blockId, actionId } = interaction; + const button: ButtonElement = { + type: BlockElementType.BUTTON, + text: { + type: TextObjectType.PLAIN_TEXT, + text, + }, + appId: this.appId, + blockId, + actionId, + url, + value, + style, + }; + return button; + } + + public addDropDown( + param: StaticSelectElementParam, + interaction: ElementInteractionParam, + ): StaticSelectElement { + const { + placeholder, + options, + optionGroups, + initialOption, + initialValue, + dispatchActionConfig, + } = param; + const { blockId, actionId } = interaction; + const dropDown: StaticSelectElement = { + type: BlockElementType.STATIC_SELECT, + placeholder: { + type: TextObjectType.PLAIN_TEXT, + text: placeholder, + }, + options, + optionGroups, + initialOption, + initialValue, + appId: this.appId, + blockId, + actionId, + dispatchActionConfig, + }; + return dropDown; + } + + public addMultiStaticSelect( + param: MultiStaticSelectElementParam, + interaction: ElementInteractionParam, + ): MultiStaticSelectElement { + const { + placeholder, + options, + optionGroups, + dispatchActionConfig, + } = param; + const { blockId, actionId } = interaction; + const dropDown: MultiStaticSelectElement = { + type: BlockElementType.MULTI_STATIC_SELECT, + placeholder: { + type: TextObjectType.PLAIN_TEXT, + text: placeholder, + }, + options, + optionGroups, + appId: this.appId, + blockId, + actionId, + dispatchActionConfig, + }; + return dropDown; + } + + + public createDropDownOptions( + param: StaticSelectOptionsParam, + ): Array