From 5238e323a89d498a59253461bac2c5a24534a808 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Mon, 3 Feb 2025 10:05:14 +0100 Subject: [PATCH] Update Slack with latest product changes (#697) * Remove action to save thread * Rename "Lens" to "AskAI" * Fix ask ai with new API * Continue * Fix all TS errors * Changeset and format * Fix error in copilot * Changeset for api * Apply suggestions from code review Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> --------- Co-authored-by: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> --- .changeset/chilly-boxes-fold.md | 5 + .changeset/sour-rockets-occur.md | 5 + integrations/github-copilot/src/copilot.ts | 2 +- integrations/slack/package.json | 1 + integrations/slack/slack-manifest.yaml | 6 +- integrations/slack/src/actions/index.ts | 3 +- .../actions/{queryLens.ts => queryAskAI.ts} | 118 +++---- integrations/slack/src/actions/saveThread.ts | 300 ------------------ integrations/slack/src/handlers/actions.ts | 75 ++--- integrations/slack/src/handlers/commands.ts | 7 +- integrations/slack/src/handlers/events.ts | 12 +- integrations/slack/src/handlers/handlers.ts | 68 ++-- integrations/slack/src/handlers/index.ts | 1 - integrations/slack/src/handlers/search.ts | 140 -------- integrations/slack/src/index.ts | 81 +---- integrations/slack/src/links.ts | 2 +- integrations/slack/src/middlewares.ts | 40 +-- integrations/slack/src/router.ts | 28 +- integrations/slack/src/slack.ts | 36 ++- integrations/slack/src/ui/blocks/index.ts | 4 +- integrations/slack/src/utils.ts | 23 +- 21 files changed, 195 insertions(+), 762 deletions(-) create mode 100644 .changeset/chilly-boxes-fold.md create mode 100644 .changeset/sour-rockets-occur.md rename integrations/slack/src/actions/{queryLens.ts => queryAskAI.ts} (78%) delete mode 100644 integrations/slack/src/actions/saveThread.ts delete mode 100644 integrations/slack/src/handlers/search.ts diff --git a/.changeset/chilly-boxes-fold.md b/.changeset/chilly-boxes-fold.md new file mode 100644 index 000000000..62081aa28 --- /dev/null +++ b/.changeset/chilly-boxes-fold.md @@ -0,0 +1,5 @@ +--- +'@gitbook/integration-slack': major +--- + +Remove deprecated "save" feature, and fix ask questions diff --git a/.changeset/sour-rockets-occur.md b/.changeset/sour-rockets-occur.md new file mode 100644 index 000000000..52e5fcb4d --- /dev/null +++ b/.changeset/sour-rockets-occur.md @@ -0,0 +1,5 @@ +--- +'@gitbook/api': minor +--- + +Update API client with latest OpenAPI spec diff --git a/integrations/github-copilot/src/copilot.ts b/integrations/github-copilot/src/copilot.ts index 0d6bf662b..f72b0b2d6 100644 --- a/integrations/github-copilot/src/copilot.ts +++ b/integrations/github-copilot/src/copilot.ts @@ -106,7 +106,7 @@ export async function* streamCopilotResponse( return { type: source.type, id: source.page, - data: source, + data: source as any, is_implicit: false, metadata: { display_name: page.title, diff --git a/integrations/slack/package.json b/integrations/slack/package.json index 86fd7c41e..d0b5bcfae 100644 --- a/integrations/slack/package.json +++ b/integrations/slack/package.json @@ -14,6 +14,7 @@ "@gitbook/tsconfig": "workspace:*" }, "scripts": { + "typecheck": "tsc --noEmit", "check": "gitbook check", "publish-integrations": "gitbook publish .", "publish-integrations-staging": "gitbook publish ." diff --git a/integrations/slack/slack-manifest.yaml b/integrations/slack/slack-manifest.yaml index 669ca068a..454b358a5 100644 --- a/integrations/slack/slack-manifest.yaml +++ b/integrations/slack/slack-manifest.yaml @@ -5,13 +5,9 @@ display_information: long_description: | Experience the power of GitBook in your Slack workspace! 🚀 - 💾 *Save Threads in GitBook:* - - Simply type ‘@gitbook save' in your Slack thread, and we'll swiftly capture it, turning your discussion into a living document. Now, anyone can easily search your team's captures and documents whenever they need them. - 🔍 *Effortless Search:* - Start by using @gitbook [question]. Our advanced AI quickly scans your knowledge base, providing accurate and concise responses within seconds. + Start by using @gitbook [question]. Our advanced AI quickly scans your documentation, providing accurate and concise responses within seconds. 📢 *Stay Informed:* diff --git a/integrations/slack/src/actions/index.ts b/integrations/slack/src/actions/index.ts index 9748767ad..fea74aacf 100644 --- a/integrations/slack/src/actions/index.ts +++ b/integrations/slack/src/actions/index.ts @@ -1,2 +1 @@ -export * from './queryLens'; -export * from './saveThread'; +export * from './queryAskAI'; diff --git a/integrations/slack/src/actions/queryLens.ts b/integrations/slack/src/actions/queryAskAI.ts similarity index 78% rename from integrations/slack/src/actions/queryLens.ts rename to integrations/slack/src/actions/queryAskAI.ts index c320e58f2..7bfb12473 100644 --- a/integrations/slack/src/actions/queryLens.ts +++ b/integrations/slack/src/actions/queryAskAI.ts @@ -12,7 +12,6 @@ import { SlackRuntimeEnvironment, SlackRuntimeContext, } from '../configuration'; -import { acknowledgeQuery } from '../middlewares'; import { slackAPI } from '../slack'; import { QueryDisplayBlock, ShareTools, decodeSlackEscapeChars, Spacer, SourcesBlock } from '../ui'; import { getInstallationApiClient, stripBotName, stripMarkdown } from '../utils'; @@ -23,7 +22,7 @@ export type RelatedSource = { page: { path?: string; title: string }; }; -export interface IQueryLens { +export interface IQueryAskAI { channelId: string; channelName?: string; responseUrl?: string; @@ -37,7 +36,7 @@ export interface IQueryLens { /* needed for postEphemeral */ userId?: string; - /* Get lens reply in thread */ + /* Get AskAI reply in thread */ threadId?: string; authorization?: string; @@ -65,7 +64,7 @@ const capitalizeFirstLetter = (text: string) => text?.trim().charAt(0).toUpperCase() + text?.trim().slice(1); /* - * Pulls out the top related pages from page IDs returned from Lens and resolves them using a provided GitBook API client. + * Pulls out the top related pages from page IDs returned from AskAI and resolves them using a provided GitBook API client. */ async function getRelatedSources(params: { sources?: SearchAIAnswer['sources']; @@ -91,7 +90,7 @@ async function getRelatedSources(params: { }, new Set()); // query for all Revisions (accounting for spaces that might not exist or any errors) - const allRevisions: Array = ( + const allRevisions = ( await Promise.allSettled( Array.from(allSpaces).map((space) => client.spaces.getCurrentRevision(space)), ) @@ -100,7 +99,7 @@ async function getRelatedSources(params: { accum.push(result.value.data); } return accum; - }, []); + }, [] as Array); const getResolvedPage = (page: SearchAIAnswerSource & { type: 'page' }) => { // TODO: we can probably combine finding the currentRevision with extracting the appropriate page @@ -114,6 +113,10 @@ async function getRelatedSources(params: { const allRevisionPages = extractAllPages(currentRevision.pages); const revisionPage = allRevisionPages.find((revPage) => revPage.id === page.page); + if (!revisionPage || revisionPage.type !== 'document') { + return null; + } + return { id: page.page, sourceUrl, @@ -122,66 +125,28 @@ async function getRelatedSources(params: { } }; - const getResolvedSnippet = async ( - source: SearchAIAnswerSource & { type: 'snippet' | 'capture' }, - ): Promise => { - const snippetRequest = await client.orgs.getSnippet(organization, source.captureId); - const snippet = snippetRequest.data; - - const sourceUrl = snippet.urls.app; - - return { - id: snippet.id, - sourceUrl, - page: { title: snippet.title }, - }; - }; - - const resolvedSnippetsPromises = await Promise.allSettled( - topSources - .filter((source) => source.type === 'capture' || source.type === 'snippet') - .map(getResolvedSnippet), - ); - - const resolvedSnippets = resolvedSnippetsPromises.reduce((accum, result) => { - if (result.status === 'fulfilled') { - accum.push(result.value); - } - - return accum; - }, [] as RelatedSource[]); - // extract all related sources from the Revisions along with the related public URL - const relatedSources: Array = topSources.reduce((accum, source) => { + const relatedSources = topSources.reduce((accum, source) => { switch (source.type) { case 'page': const resolvedPage = getResolvedPage(source); - accum.push(resolvedPage); - break; - - case 'snippet': - case 'capture': - const resolvedSnippet = resolvedSnippets.find( - (snippet) => snippet.id === source.captureId, - ); - - if (resolvedSnippet) { - accum.push(resolvedSnippet); + if (resolvedPage) { + accum.push(resolvedPage); } break; } return accum; - }, []); + }, [] as Array); // filter related sources from current revision return relatedSources; } /* - * Queries GitBook Lens via the GitBook API and posts the answer in the form of Slack UI Blocks back to the original channel/conversation/thread. + * Queries GitBook AskAI via the GitBook API and posts the answer in the form of Slack UI Blocks back to the original channel/conversation/thread. */ -export async function queryLens({ +export async function queryAskAI({ channelId, teamId, threadId, @@ -193,7 +158,7 @@ export async function queryLens({ responseUrl, channelName, -}: IQueryLens) { +}: IQueryAskAI) { const { environment, api } = context; const { client, installation } = await getInstallationApiClient(api, teamId); if (!installation) { @@ -208,25 +173,44 @@ export async function queryLens({ const parsedQuery = stripMarkdown(stripBotName(text, authorization?.user_id)); // async acknowledge the request to the end user early - acknowledgeQuery({ + slackAPI( context, - text: parsedQuery, - userId, - threadId, - channelId, - responseUrl, - accessToken, - messageType, - }); - - const result = await client.orgs.askInOrganization(installation.target.organization, { - query: parsedQuery, - }); - const answer: SearchAIAnswer = result.data?.answer; + { + method: 'POST', + path: messageType === 'ephemeral' ? 'chat.postEphemeral' : 'chat.postMessage', + responseUrl, + payload: { + channel: channelId, + text: `_Asking: ${stripMarkdown(text)}_`, + ...(userId ? { user: userId } : {}), // actually shouldn't be optional + ...(threadId ? { thread_ts: threadId } : {}), + }, + }, + { + accessToken, + }, + ); + + const result = await client.orgs.askInOrganization( + installation.target.organization, + { + query: parsedQuery, + }, + { + format: 'markdown', + }, + ); + const answer = result.data?.answer; const messageTypePath = messageType === 'ephemeral' ? 'chat.postEphemeral' : 'chat.postMessage'; - if (answer && answer.text) { + if (answer && answer.answer) { + if (!('markdown' in answer.answer)) { + throw new Error('Answer is not in markdown format'); + } + + const answerText = capitalizeFirstLetter(answer.answer.markdown); + const relatedSources = await getRelatedSources({ sources: answer.sources, client, @@ -234,8 +218,6 @@ export async function queryLens({ organization: installation.target.organization, }); - const answerText = capitalizeFirstLetter(answer.text); - const header = text.length > 150 ? `${text.slice(0, 140)}...` : text; const blocks = [ { diff --git a/integrations/slack/src/actions/saveThread.ts b/integrations/slack/src/actions/saveThread.ts deleted file mode 100644 index f6513073d..000000000 --- a/integrations/slack/src/actions/saveThread.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { SlackRuntimeContext } from '../configuration'; -import { slackAPI } from '../slack'; -import { ConversationSavedBlock, QueryDisplayBlock } from '../ui'; -import { getInstallationApiClient, getInstallationConfig, isSaveThreadMessage } from '../utils'; - -const RUNTIME_TIME_LIMIT = 30000; -const APP_ORG_URL = 'https://app.gitbook.com/o/'; - -/** - * Save thread in GitBook as a summary (snippet) - */ -export async function saveThread( - { - teamId, - channelId, - thread_ts, - userId, - }: { - teamId: string; - channelId: string; - thread_ts: string; - userId: string; - }, - context: SlackRuntimeContext, -) { - const { accessToken, installation } = await getInstallationConfig(context, teamId); - - // acknowledge the request to the user - notifySavingThread({ channel: channelId, thread_ts, userId }, context, accessToken); - - const snippetsURL = `${APP_ORG_URL}${installation.target.organization}/snippets`; - // In some cases, the runtime limit is reached before the capture is finished (e.g large threads) - // we notify the user before the runtime limit is reached that the capture - const timeoutId = registerNotifyBeforeRuntimeLimit( - { - channel: channelId, - thread_ts, - userId, - }, - snippetsURL, - context, - accessToken, - ); - - const { capture, followupQuestions } = await createMessageThreadCapture( - { - team_id: teamId, - channel: channelId, - thread_ts, - }, - context, - ); - - // managed to avoid the timeout, clear the timeout - clearTimeout(timeoutId); - - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel: channelId, - blocks: [ - ...ConversationSavedBlock(capture.urls.app), - ...QueryDisplayBlock({ - queries: followupQuestions, - heading: 'Here some questions this thread can help answer:', - }), - ], - thread_ts, - user: userId, - unfurl_links: false, - }, - }, - { accessToken }, - ); -} - -function slackTimestampToISOFormat(slackTs) { - const timestampInMilliseconds = parseFloat(slackTs) * 1000; - - const date = new Date(timestampInMilliseconds); - - const formattedDate = date.toISOString(); - - return formattedDate; -} - -/** - * Creates a capture from a message thread - * - * 1. Get all messages in a thread - * 2. Start capture - * 3. Add all messages in a thread as capture events - * 4. Stop capture - */ -async function createMessageThreadCapture(slackEvent, context: SlackRuntimeContext) { - const { api } = context; - - const { team_id, channel, thread_ts } = slackEvent; - const { client: installationApiClient, installation } = await getInstallationApiClient( - api, - team_id, - ); - const orgId = installation.target.organization; - - const { accessToken } = await getInstallationConfig(context, team_id); - - const messageReplies = await slackAPI( - context, - { - method: 'GET', - path: 'conversations.replies', - payload: { - channel, - ts: thread_ts, - }, - }, - { accessToken }, - ); - - const { messages = [] } = messageReplies; - - // get a permalink to the thread - const permalinkRes = (await slackAPI( - context, - { - method: 'GET', - path: 'chat.getPermalink', - payload: { - channel, - message_ts: thread_ts, - }, - }, - { - accessToken, - }, - )) as { ok: boolean; permalink: string }; - - // TODO what's to be done with new permissions needed "capture:write" and users needing to re-auth - - // start capture - const startCaptureRes = await installationApiClient.orgs.startCapture(orgId, { - context: 'thread', - externalId: thread_ts, - ...(permalinkRes.ok ? { externalURL: permalinkRes.permalink } : {}), - }); - - const capture = startCaptureRes.data; - - const events = messages - .filter((message) => { - // ignore messages in thread from any bot (todo: potentially limit to gitbook only) - if (message.bot_id) { - return false; - } - - if (isSaveThreadMessage(message.text)) { - return false; - } - - return true; - }) - .map((message) => { - const { text, ts, thread_ts } = message; - - return { - type: 'thread.message', - text, - timestamp: slackTimestampToISOFormat(ts), - ...(ts === thread_ts ? { isFirst: true } : {}), - }; - }); - - // add all messages in a thread to a capture - await installationApiClient.orgs.addEventsToCapture(orgId, capture.id, { - events, - }); - - // stop capture - const stopCaptureRes = await installationApiClient.orgs.stopCapture( - orgId, - capture.id, - {}, // remove in api - { - format: 'markdown', - }, - ); - - const outputCapture = stopCaptureRes.data; - - return outputCapture; -} - -export async function notifyOnlySupportedThreads(context, team, channel, user) { - const { accessToken } = await getInstallationConfig(context, team); - - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `Sorry I'm only supported in threads for now :sweat_smile:`, - }, - }, - ], - user, - unfurl_links: false, - }, - }, - { accessToken }, - ); -} - -async function notifySavingThread( - { - channel, - thread_ts, - userId, - }: { - channel: string; - thread_ts: string; - userId: string; - }, - context: SlackRuntimeContext, - accessToken: string, -) { - // acknowledge the request to the user - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postEphemeral', // probably alwasy ephemeral? or otherwise have replies in same thread - payload: { - channel, - text: `_Saving to GitBook..._`, - ...(userId ? { user: userId } : {}), // actually shouldn't be optional - thread_ts, - }, - }, - { - accessToken, - }, - ); -} - -function registerNotifyBeforeRuntimeLimit( - { - channel, - thread_ts, - userId, - }: { - channel: string; - thread_ts: string; - userId: string; - }, - snippetsUrl: string, - context: SlackRuntimeContext, - accessToken: string, -) { - const timeoutId = setTimeout(async () => { - // acknowledge the request to the user - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `Thread is being saved. You'll find the snippet in <${snippetsUrl}|GitBook> shortly.`, - }, - }, - ], - ...(userId ? { user: userId } : {}), // actually shouldn't be optional - thread_ts, - }, - }, - { - accessToken, - }, - ); - - // Set to a value slightly less than the actual runtime limit to notify just before - }, RUNTIME_TIME_LIMIT - 1000); - - return timeoutId; -} diff --git a/integrations/slack/src/handlers/actions.ts b/integrations/slack/src/handlers/actions.ts index 1fa1b6059..d6d516727 100644 --- a/integrations/slack/src/handlers/actions.ts +++ b/integrations/slack/src/handlers/actions.ts @@ -1,48 +1,41 @@ -import { type IQueryLens } from '../actions'; +import { queryAskAI, type IQueryAskAI } from '../actions'; +import { SlackRuntimeContext } from '../configuration'; import { getActionNameAndType, parseActionPayload } from '../utils'; /** - * Handle an action from Slack. * Actions are defined within a block using Slack's "action_id" and are usually in the form of "functionName:messageType" */ -export function createSlackActionsHandler( - handlers: { - [type: string]: (event: object) => Promise; - }, - // TODO: type output -): any { - return async (request, context) => { - const actionPayload = await parseActionPayload(request); - - const { actions, container, channel, team, user } = actionPayload; - - // go through all actions sent and call the action from './actions/index.ts' - if (actions?.length > 0) { - const action = actions[0]; - const { actionName, actionPostType } = getActionNameAndType(action.action_id); - - // dispatch the action to an appropriate action function - if (actionName === 'queryLens') { - const params: IQueryLens = { - channelId: channel.id, - teamId: team.id, - text: action.value ?? action.text.text, - messageType: actionPostType as 'ephemeral' | 'permanent', - - // pass thread if exists - ...(container.thread_ts ? { threadId: container.thread_ts } : {}), - // pass user if exists - ...(user.id ? { userId: user.id } : {}), - - context, - }; - - // queryLens:ephemeral, queryLens:permanent - const handlerPromise = handlers[actionName](params); - - context.waitUntil(handlerPromise); - } +export const slackActionsHandler = async (request: Request, context: SlackRuntimeContext) => { + const actionPayload = await parseActionPayload(request); + + const { actions, container, channel, team, user } = actionPayload; + + // go through all actions sent and call the action from './actions/index.ts' + if (actions?.length > 0) { + const action = actions[0]; + const { actionName, actionPostType } = getActionNameAndType(action.action_id); + + // dispatch the action to an appropriate action function + if (actionName === 'queryAskAI') { + const params: IQueryAskAI = { + channelId: channel.id, + teamId: team.id, + text: action.value ?? action.text.text, + messageType: actionPostType as 'ephemeral' | 'permanent', + + // pass thread if exists + ...(container.thread_ts ? { threadId: container.thread_ts } : {}), + // pass user if exists + ...(user.id ? { userId: user.id } : {}), + + context, + }; + + // queryAskAI:ephemeral, queryAskAI:permanent + const handlerPromise = queryAskAI(params); + + context.waitUntil(handlerPromise); } - }; -} + } +}; diff --git a/integrations/slack/src/handlers/commands.ts b/integrations/slack/src/handlers/commands.ts index 722a0c6c0..ec8994e00 100644 --- a/integrations/slack/src/handlers/commands.ts +++ b/integrations/slack/src/handlers/commands.ts @@ -1,5 +1,3 @@ -import { FetchEventCallback } from '@gitbook/runtime'; - import { SlackRuntimeContext } from '../configuration'; export interface SlashEvent { @@ -33,10 +31,11 @@ export interface SlashEvent { export function createSlackCommandsHandler(handlers: { [type: string]: (slashEvent: SlashEvent, context: SlackRuntimeContext) => Promise; -}): FetchEventCallback { - return async (request, context) => { +}) { + return async (request: Request, context: SlackRuntimeContext) => { const requestText = await request.text(); + // @ts-ignore const slashEvent: SlashEvent = Object.fromEntries( new URLSearchParams(requestText).entries(), ); diff --git a/integrations/slack/src/handlers/events.ts b/integrations/slack/src/handlers/events.ts index 304e18b83..7e91fcf79 100644 --- a/integrations/slack/src/handlers/events.ts +++ b/integrations/slack/src/handlers/events.ts @@ -8,16 +8,22 @@ import { isAllowedToRespond, parseEventPayload } from '../utils'; */ export function createSlackEventsHandler( handlers: { - [type: string]: (event: object, context: SlackRuntimeContext) => Promise; + [type: string]: (event: any, context: SlackRuntimeContext) => Promise; }, fallback?: FetchEventCallback, -): FetchEventCallback { - return async (request, context) => { +) { + return async (request: Request, context: SlackRuntimeContext) => { const eventPayload = await parseEventPayload(request); // url_verification doesn't have an event object const { type } = eventPayload.event ?? eventPayload; + if (!type) { + return new Response(`No event type found`, { + status: 404, + }); + } + const handler = handlers[type]; // check if we are handling this event at this stage. if not, forward on to the fallback diff --git a/integrations/slack/src/handlers/handlers.ts b/integrations/slack/src/handlers/handlers.ts index 7ab6415f8..cc54c6102 100644 --- a/integrations/slack/src/handlers/handlers.ts +++ b/integrations/slack/src/handlers/handlers.ts @@ -1,22 +1,22 @@ import { Logger } from '@gitbook/runtime'; import type { SlashEvent } from './commands'; -import { notifyOnlySupportedThreads, queryLens, saveThread } from '../actions'; +import { queryAskAI } from '../actions'; import { SlackRuntimeContext } from '../configuration'; -import { isAllowedToRespond, isSaveThreadMessage, stripBotName } from '../utils'; +import { isAllowedToRespond, stripBotName } from '../utils'; const logger = Logger('slack:api'); /** - * Handle a slash request and route it to the GitBook Lens' query function. + * Handle a slash request and route it to the GitBook AskAI' query function. */ -export async function queryLensSlashHandler(slashEvent: SlashEvent, context: SlackRuntimeContext) { - // pull out required params from the slashEvent for queryLens +export async function queryAskAISlashHandler(slashEvent: SlashEvent, context: SlackRuntimeContext) { + // pull out required params from the slashEvent for queryAskAI const { team_id, channel_id, thread_ts, user_id, text, channel_name, response_url } = slashEvent; try { - return queryLens({ + return queryAskAI({ channelId: channel_id, channelName: channel_name, responseUrl: response_url, @@ -29,16 +29,16 @@ export async function queryLensSlashHandler(slashEvent: SlashEvent, context: Sla }); } catch (e) { // Error state. Probably no installation was found - logger.error('Error calling queryLens. Perhaps no installation was found?'); + logger.error('Error calling queryAskAI. Perhaps no installation was found?'); return {}; } } /** - * Handle an Event request and route it to the GitBook Lens' query function. + * Handle an Event request and route it to the GitBook AskAI' query function. */ export async function messageEventHandler(eventPayload: any, context: SlackRuntimeContext) { - // pull out required params from the event for queryLens + // pull out required params from the event for queryAskAI const { type, text, thread_ts, channel, user, team } = eventPayload.event; // check for bot_id so that the bot doesn't trigger itself @@ -47,8 +47,8 @@ export async function messageEventHandler(eventPayload: any, context: SlackRunti // @ts-ignore const parsedQuery = stripBotName(text, eventPayload.authorizations[0]?.user_id); - // send to Lens - await queryLens({ + // send to AskAI + await queryAskAI({ teamId: team, channelId: channel, threadId: thread_ts, @@ -68,10 +68,10 @@ export async function messageEventHandler(eventPayload: any, context: SlackRunti } /** - * Handle an Event request and route it to either GitBook Lens' query function or saveThread function. + * Handle an Event request and route it to AskAI's query function. */ export async function appMentionEventHandler(eventPayload: any, context: SlackRuntimeContext) { - // pull out required params from the slashEvent for queryLens + // pull out required params from the slashEvent for queryAskAI const { type, text, thread_ts, channel, user, team } = eventPayload.event; // check for bot_id so that the bot doesn't trigger itself @@ -80,35 +80,17 @@ export async function appMentionEventHandler(eventPayload: any, context: SlackRu // @ts-ignore const parsedMessage = stripBotName(text, eventPayload.authorizations[0]?.user_id); - if (isSaveThreadMessage(parsedMessage)) { - // not supported outside threads - if (!thread_ts) { - await notifyOnlySupportedThreads(context, team, channel, user); - return; - } - - await saveThread( - { - teamId: team, - channelId: channel, - thread_ts, - userId: user, - }, - context, - ); - } else { - // send to Lens - await queryLens({ - teamId: team, - channelId: channel, - threadId: thread_ts, - userId: user, - messageType: 'permanent', - text: parsedMessage, - context, - // @ts-ignore - authorization: eventPayload.authorizations[0], - }); - } + // send to AskAI + await queryAskAI({ + teamId: team, + channelId: channel, + threadId: thread_ts, + userId: user, + messageType: 'permanent', + text: parsedMessage, + context, + // @ts-ignore + authorization: eventPayload.authorizations[0], + }); } } diff --git a/integrations/slack/src/handlers/index.ts b/integrations/slack/src/handlers/index.ts index b5c4c7336..85dfcf41c 100644 --- a/integrations/slack/src/handlers/index.ts +++ b/integrations/slack/src/handlers/index.ts @@ -1,4 +1,3 @@ -export * from './search'; export * from './handlers'; export * from './actions'; export * from './commands'; diff --git a/integrations/slack/src/handlers/search.ts b/integrations/slack/src/handlers/search.ts deleted file mode 100644 index 1de339e4b..000000000 --- a/integrations/slack/src/handlers/search.ts +++ /dev/null @@ -1,140 +0,0 @@ -import type { SearchPageResult, SearchSectionResult, SearchSpaceResult } from '@gitbook/api'; - -import type { SlashEvent } from '../commands'; -import { SlackInstallationConfiguration, SlackRuntimeContext } from '../configuration'; -import { slackAPI } from '../slack'; - -/** - * Search for a query in GitBook and post a message to Slack. - */ -export async function searchInGitBook(slashEvent: SlashEvent, context: SlackRuntimeContext) { - const { environment, api } = context; - const { team_id, channel_id, text } = slashEvent; - - // Lookup the concerned installations - const { - data: { items: installations }, - } = await api.integrations.listIntegrationInstallations(environment.integration.name, { - externalId: team_id, - }); - - /** - * TODO: Prompt user to select a GitBook installation if there is more than one. - * by showing org names in a dropdown and asking user to pick one - */ - const installation = installations[0]; - if (!installation) { - return {}; - } - - const accessToken = (installation.configuration as SlackInstallationConfiguration) - .oauth_credentials?.access_token; - - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel: channel_id, - text: `_Searching for query: ${text}_`, - }, - }, - { - accessToken, - }, - ); - - // Authentify as the installation - const installationApiClient = await api.createInstallationClient( - environment.integration.name, - installation.id, - ); - - const { - data: { items }, - } = await installationApiClient.search.searchContent({ query: text, limit: 5 }); - - await slackAPI( - context, - { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel: channel_id, - blocks: buildSearchContentBlocks(text, items), - unfurl_links: false, - unfurl_media: false, - }, - }, - { - accessToken, - }, - ); -} - -function buildSearchContentBlocks(query: string, items: SearchSpaceResult[]) { - const queryBlock = { - type: 'section', - text: { - type: 'mrkdwn', - text: `Showing results for query: *${query}*`, - }, - }; - - const blocks = items - .flatMap((space) => space.pages) - .reduce>( - (acc, page) => { - const pageResultBlock = buildSearchPageBlock(page); - if (page.sections) { - const sectionBlocks = page.sections.map(buildSearchSectionBlock); - acc.push(...pageResultBlock, ...sectionBlocks); - } - return acc; - }, - [queryBlock], - ); - - return blocks.flat(); -} - -function buildSearchPageBlock(page: SearchPageResult) { - return [ - { - type: 'divider', - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `*<${page.urls.app}|:page_facing_up: ${page.title}>*`, - }, - }, - ]; -} - -function buildSearchSectionBlock(section: SearchSectionResult) { - const title = section.title ? `*${section.title.replace(/"/g, '')}*` : ``; - const body = ` - _${section.body.replace(/"/g, '').split('\n').join('').slice(0, 128)}_`; - const text = `:hash: ${title}${body}`; - return [ - { - type: 'section', - text: { - type: 'mrkdwn', - text, - }, - accessory: { - type: 'button', - text: { - type: 'plain_text', - text: 'View', - emoji: true, - }, - url: section.urls.app, - action_id: section.id, - }, - }, - ]; -} diff --git a/integrations/slack/src/index.ts b/integrations/slack/src/index.ts index 164853114..c91473cff 100644 --- a/integrations/slack/src/index.ts +++ b/integrations/slack/src/index.ts @@ -16,7 +16,7 @@ const handleSpaceContentUpdated: EventCallback< const { environment, api } = context; const channel = environment.spaceInstallation?.configuration?.channel || - environment.installation.configuration.default_channel; + environment.installation?.configuration.default_channel; if (!channel) { // Integration not yet configured. @@ -28,22 +28,19 @@ const handleSpaceContentUpdated: EventCallback< return; } - const { data: semanticChanges } = await api.spaces.getRevisionSemanticChanges( - event.spaceId, - event.revisionId, - { - // Ignore git metadata and custom field changes + const [{ data: semanticChanges }, { data: space }] = await Promise.all([ + api.spaces.getRevisionSemanticChanges(event.spaceId, event.revisionId, { + // Ignore git metadata changes metadata: false, - }, - ); + }), + api.spaces.getSpaceById(event.spaceId), + ]); if (semanticChanges.changes.length === 0) { // No changes to notify about return; } - const { data: space } = await api.spaces.getSpaceById(event.spaceId); - /* * Build a notification that looks something like this: * @@ -61,13 +58,13 @@ const handleSpaceContentUpdated: EventCallback< * And another X changes not listed here. */ - const createdPages = []; - const editedPages = []; - const deletedPages = []; - const movedPages = []; - const createdFiles = []; - const editedFiles = []; - const deletedFiles = []; + const createdPages: ChangedRevisionPage[] = []; + const editedPages: ChangedRevisionPage[] = []; + const deletedPages: ChangedRevisionPage[] = []; + const movedPages: ChangedRevisionPage[] = []; + const createdFiles: string[] = []; + const editedFiles: string[] = []; + const deletedFiles: string[] = []; semanticChanges.changes.forEach((change) => { switch (change.type) { @@ -140,7 +137,7 @@ const handleSpaceContentUpdated: EventCallback< notificationText += `\n*Deleted files:*\n${renderList(deletedFiles)}\n\n`; } - if (semanticChanges.more > 0) { + if (semanticChanges.more && semanticChanges.more > 0) { notificationText += `\n\nAnd another ${semanticChanges.more} changes not listed here.\n`; } } @@ -165,58 +162,10 @@ const handleSpaceContentUpdated: EventCallback< }); }; -/* - * Handle content visibility being updated: send a notification on Slack. - */ -const handleSpaceVisibilityUpdated: EventCallback< - 'space_visibility_updated', - SlackRuntimeContext -> = async (event, context) => { - const { environment, api } = context; - - const channel = - environment.spaceInstallation?.configuration?.channel || - environment.installation.configuration.default_channel; - - if (!channel) { - // Integration not yet configured on a channel - return; - } - - if (environment.spaceInstallation?.configuration?.notify_visibility_update === false) { - // Visibility updates are turned off - return; - } - - const { spaceId, previousVisibility, visibility } = event; - - const { data: space } = await api.spaces.getSpaceById(spaceId); - - await slackAPI(context, { - method: 'POST', - path: 'chat.postMessage', - payload: { - channel, - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `The visibility of *<${space.urls.app}|${ - space.title || 'Space' - }>* has been changed from *${previousVisibility}* to *${visibility}*`, - }, - }, - ], - }, - }); -}; - export default createIntegration({ fetch: handleFetchEvent, events: { space_content_updated: handleSpaceContentUpdated, - space_visibility_updated: handleSpaceVisibilityUpdated, }, }); diff --git a/integrations/slack/src/links.ts b/integrations/slack/src/links.ts index cef55890c..5886af5bd 100644 --- a/integrations/slack/src/links.ts +++ b/integrations/slack/src/links.ts @@ -43,7 +43,7 @@ export async function unfurlLink(event: LinkSharedSlackEvent, context: SlackRunt const installationApiClient = await api.createInstallationClient('slack', installation.id); // Resolve links to their content - const unfurls = {}; + const unfurls: Record = {}; await Promise.all( event.event.links.map(async (link) => { const { data: content } = await installationApiClient.urls.getContentByUrl({ diff --git a/integrations/slack/src/middlewares.ts b/integrations/slack/src/middlewares.ts index e3d876a39..c50af0b08 100644 --- a/integrations/slack/src/middlewares.ts +++ b/integrations/slack/src/middlewares.ts @@ -12,11 +12,13 @@ export async function verifySlackRequest(request: Request, { environment }: Slac // Clone the request as to not use up the only read the body allows for future requests const req = request.clone(); - // @ts-ignore const slackSignature = req.headers.get('x-slack-signature'); - // @ts-ignore const slackTimestamp = req.headers.get('x-slack-request-timestamp'); + if (!slackSignature || !slackTimestamp) { + throw new Error('Missing Slack signature or timestamp'); + } + // Check for replay attacks const now = Math.floor(Date.now() / 1000); if (Math.abs(now - Number(slackTimestamp)) > 60 * 5) { @@ -36,40 +38,6 @@ export async function verifySlackRequest(request: Request, { environment }: Slac } } -export function acknowledgeQuery({ - context, - text, - userId, - channelId, - responseUrl, - threadId, - accessToken, - messageType = 'ephemeral', -}) { - const slackMessageTypes = { - ephemeral: 'chat.postEphemeral', - permanent: 'chat.postMessage', - }; - - return slackAPI( - context, - { - method: 'POST', - path: slackMessageTypes[messageType], // probably alwasy ephemeral? or otherwise have replies in same thread - responseUrl, - payload: { - channel: channelId, - text: `_Asking: ${stripMarkdown(text)}_`, - ...(userId ? { user: userId } : {}), // actually shouldn't be optional - ...(threadId ? { thread_ts: threadId } : {}), - }, - }, - { - accessToken, - }, - ); -} - /** * We acknowledge the slack request immediately to avoid failures * and "queue" the actual task to be executed in a subsequent request. diff --git a/integrations/slack/src/router.ts b/integrations/slack/src/router.ts index f73bdbc93..7cc587c1d 100644 --- a/integrations/slack/src/router.ts +++ b/integrations/slack/src/router.ts @@ -1,13 +1,12 @@ import { Router } from 'itty-router'; -import { createOAuthHandler, FetchEventCallback } from '@gitbook/runtime'; +import { createOAuthHandler, FetchEventCallback, OAuthResponse } from '@gitbook/runtime'; -import { queryLens } from './actions'; import { createSlackEventsHandler, createSlackCommandsHandler, - createSlackActionsHandler, - queryLensSlashHandler, + slackActionsHandler, + queryAskAISlashHandler, messageEventHandler, appMentionEventHandler, } from './handlers'; @@ -51,7 +50,13 @@ export const handleFetchEvent: FetchEventCallback = async (request, context) => */ router.get( '/oauth', - createOAuthHandler({ + createOAuthHandler< + OAuthResponse & { + team: { + id: string; + }; + } + >({ clientId: environment.secrets.CLIENT_ID, clientSecret: environment.secrets.CLIENT_SECRET, // TODO: use the yaml as SoT for scopes @@ -95,14 +100,7 @@ export const handleFetchEvent: FetchEventCallback = async (request, context) => /* Handle shortcuts and interactivity via Slack UI blocks * shortcuts & interactivity */ - router.post( - '/actions', - verifySlackRequest, - createSlackActionsHandler({ - queryLens, - }), - acknowledgeSlackRequest, - ); + router.post('/actions', verifySlackRequest, slackActionsHandler, acknowledgeSlackRequest); /* Handle slash commands * eg. /gitbook [command] @@ -111,8 +109,8 @@ export const handleFetchEvent: FetchEventCallback = async (request, context) => '/commands', verifySlackRequest, createSlackCommandsHandler({ - '/gitbook': queryLensSlashHandler, - '/gitbookstaging': queryLensSlashHandler, // needed to allow our staging app to co-exist with the prod app + '/gitbook': queryAskAISlashHandler, + '/gitbookstaging': queryAskAISlashHandler, // needed to allow our staging app to co-exist with the prod app }), acknowledgeSlackRequest, ); diff --git a/integrations/slack/src/slack.ts b/integrations/slack/src/slack.ts index bb1bc7bd4..aaa8fe706 100644 --- a/integrations/slack/src/slack.ts +++ b/integrations/slack/src/slack.ts @@ -4,6 +4,16 @@ import { SlackRuntimeContext } from './configuration'; const logger = Logger('slack:api'); +type SlackResponse = { + ok: boolean; + error?: string; +} & Result; + +type SlackChannel = { + id: string; + name: string; +}; + /** * Cloudflare workers have a maximum number of subrequests we can call (20 according to my * tests) https://developers.cloudflare.com/workers/platform/limits/#how-many-subrequests-can-i-make @@ -16,9 +26,14 @@ const maximumSubrequests = 20; * results. */ export async function getChannelsPaginated(context: SlackRuntimeContext) { - const channels = []; - - let response = await slackAPI(context, { + const channels: SlackChannel[] = []; + + let response = await slackAPI<{ + channels: SlackChannel[]; + response_metadata: { + next_cursor?: string; + }; + }>(context, { method: 'GET', path: 'conversations.list', payload: { @@ -52,15 +67,14 @@ export async function getChannelsPaginated(context: SlackRuntimeContext) { // Remove any duplicate as the pagination API could return duplicated channels return channels.filter( - (value, index, self) => - index === self.findIndex((t) => t.place === value.place && t.id === value.id), + (value, index, self) => index === self.findIndex((t) => t.id === value.id), ); } /** * Execute a Slack API request and return the result. */ -export async function slackAPI( +export async function slackAPI( context: SlackRuntimeContext, request: { method: string; @@ -72,7 +86,7 @@ export async function slackAPI( accessToken?: string; } = {}, retriesLeft = 1, -) { +): Promise> { const { environment } = context; const accessToken = @@ -117,7 +131,7 @@ export async function slackAPI( throw new Error(`${response.status} ${response.statusText}`); } - const result = await response.json(); + const result = await response.json>(); if (!result.ok) { if (retriesLeft > 0) { @@ -135,6 +149,7 @@ export async function slackAPI( method: 'POST', path: 'conversations.join', payload: { + // @ts-ignore channel: request.payload.channel, }, }, @@ -151,8 +166,3 @@ export async function slackAPI( return result; } - -interface SlackResponse { - ok: boolean; - error?: string; -} diff --git a/integrations/slack/src/ui/blocks/index.ts b/integrations/slack/src/ui/blocks/index.ts index 7d683b44a..abc76e22b 100644 --- a/integrations/slack/src/ui/blocks/index.ts +++ b/integrations/slack/src/ui/blocks/index.ts @@ -100,7 +100,7 @@ export function FollowUpQueryList(props: { queries: Array }) { emoji: true, }, value: query, - action_id: 'queryLens:ephemeral', + action_id: 'queryAskAI:ephemeral', }, })); } @@ -118,7 +118,7 @@ export function ShareTools(text: string) { emoji: true, }, value: text, - action_id: 'queryLens:permanent', // sharing requeries for the same question verbatim which will then be pulled from cache + action_id: 'queryAskAI:permanent', // sharing requeries for the same question verbatim which will then be pulled from cache style: 'primary', }, ], diff --git a/integrations/slack/src/utils.ts b/integrations/slack/src/utils.ts index 38fb7adf4..25de85d9e 100644 --- a/integrations/slack/src/utils.ts +++ b/integrations/slack/src/utils.ts @@ -2,9 +2,7 @@ import removeMarkdown from 'remove-markdown'; import { GitBookAPI } from '@gitbook/api'; -import { SlackInstallationConfiguration } from './configuration'; - -const SAVE_THREAD_MESSAGE = 'save'; +import { SlackInstallationConfiguration, SlackRuntimeContext } from './configuration'; export function stripMarkdown(text: string) { return removeMarkdown(text); @@ -36,7 +34,7 @@ export async function getInstallationApiClient(api: GitBookAPI, externalId: stri return { client: installationApiClient, installation }; } -export async function getInstallationConfig(context, externalId) { +export async function getInstallationConfig(context: SlackRuntimeContext, externalId: string) { const { api, environment } = context; // Lookup the concerned installations @@ -106,23 +104,6 @@ export function getActionNameAndType(actionId: string) { return { actionName, actionPostType }; } -export function isSaveThreadMessage(message: string) { - const tokens = message.split(' '); - - if (tokens.length > 3) { - return false; - } - - const [first, second] = tokens; - - // `save` or `save this` or `please save` - if (first === SAVE_THREAD_MESSAGE || second === SAVE_THREAD_MESSAGE) { - return true; - } - - return false; -} - // Checks whether we should respond to a slack event export function isAllowedToRespond(eventPayload: any) { const { bot_id } = eventPayload.event;