Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tall-bikes-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/integration-slack': minor
---

Add support for ingesting conversation to Docs Agents in Slack integration
1 change: 1 addition & 0 deletions integrations/slack/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ script: ./src/index.ts
scopes:
- space:content:read
- space:metadata:read
- conversations:ingest
summary: |
# Overview
With the GitBook Slack integration, your teams have instant access to your documentation, and can get AI-summarized answers about your content.
Expand Down
2 changes: 2 additions & 0 deletions integrations/slack/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './queryAskAI';
export * from './ingestConversation';
export * from './types';
195 changes: 195 additions & 0 deletions integrations/slack/src/actions/ingestConversation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { ConversationInput, ConversationPart, IntegrationInstallation } from '@gitbook/api';
import { SlackInstallationConfiguration, SlackRuntimeContext } from '../configuration';
import {
getSlackThread,
parseSlackConversationPermalink,
slackAPI,
SlackConversationThread,
} from '../slack';
import { getInstallationApiClient, getIntegrationInstallationForTeam } from '../utils';
import { IngestSlackConversationActionParams } from './types';
import { Logger } from '@gitbook/runtime';

const logger = Logger('slack:actions:ingestConversation');

/**
* Ingest the slack conversation to GitBook aiming at improving the organization docs.
*/
export async function ingestSlackConversation(params: IngestSlackConversationActionParams) {
const { channelId, threadId, context, teamId } = params;

const installation = await getIntegrationInstallationForTeam(context, teamId);
if (!installation) {
throw new Error('Installation not found');
}

const accessToken = (installation.configuration as SlackInstallationConfiguration)
.oauth_credentials?.access_token;

const permalink = params.text;
const isIngestedFromLink = !!permalink;

const conversationToIngest: IngestSlackConversationActionParams['conversationToIngest'] =
(() => {
if (permalink) {
try {
return parseSlackConversationPermalink(permalink);
} catch (error) {
logger.debug(
`⚠️ We couldn’t understand that link. Please check it and try again.`,
error,
);
}
}

return params.conversationToIngest;
})();

if (!conversationToIngest) {
await slackAPI(
context,
{
method: 'POST',
path: 'chat.postMessage',
payload: {
channel: channelId,
text: `⚠️ We couldn’t get the conversation details. Please try again.`,
},
},
{
accessToken,
},
);

return;
}

await Promise.all([
slackAPI(
context,
{
method: 'POST',
path: 'chat.postMessage',
payload: {
channel: channelId,
...(isIngestedFromLink
? {
markdown_text: `🚀 Sharing [this conversation](${permalink}) with Docs Agent to improve your docs...`,
}
: {
text: `🚀 Sharing this conversation with Docs Agent to improve your docs...`,
}),
thread_ts: threadId,
unfurl_links: isIngestedFromLink,
},
},
{
accessToken,
},
),
handleIngestSlackConversationAction(
{
channelId,
threadId,
installation,
accessToken,
conversationToIngest,
},
context,
),
]);
}

/**
* Handle the integration action to ingest a slack conversation.
*/
export async function handleIngestSlackConversationAction(
params: {
channelId: IngestSlackConversationActionParams['channelId'];
threadId: IngestSlackConversationActionParams['threadId'];
installation: IntegrationInstallation;
accessToken: string | undefined;
conversationToIngest: {
channelId: string;
messageTs: string;
};
},
context: SlackRuntimeContext,
) {
const { channelId, threadId, installation, accessToken, conversationToIngest } = params;

const [client, conversation] = await Promise.all([
getInstallationApiClient(context, installation.id),
(async () => {
const slackThread = await getSlackThread(context, conversationToIngest, {
accessToken,
});
return parseSlackThreadAsGitBookConversation(slackThread);
})(),
]);

try {
await client.orgs.ingestConversation(installation.target.organization, conversation);
await slackAPI(
context,
{
method: 'POST',
path: 'chat.postMessage',
payload: {
channel: channelId,
text: `🤖 Got it! Docs Agent is on it. We'll analyze this and suggest changes if needed.`,
thread_ts: threadId,
},
},
{
accessToken,
},
);
} catch {
await slackAPI(
context,
{
method: 'POST',
path: 'chat.postMessage',
payload: {
channel: channelId,
text: `⚠️ Something went wrong while sending this conversation to Docs Agent.`,
thread_ts: threadId,
},
},
{
accessToken,
},
);
}
}

/**
* Parse a Slack threaded conversation into a GitBook conversation.
*/
function parseSlackThreadAsGitBookConversation(
slackThread: SlackConversationThread,
): ConversationInput {
return {
id: `${slackThread.channelId}-${slackThread.messageTs}`,
metadata: {
url: slackThread.link,
attributes: {
channelId: slackThread.channelId,
messageTs: slackThread.messageTs,
},
createdAt: new Date(slackThread.createdAt).toISOString(),
},
parts: slackThread.messages
.map((message) =>
message.text
? ({
type: 'message',
role: 'user',
body: message.text,
} as ConversationPart)
: null,
)
.filter((part) => part !== null),
};
}
38 changes: 7 additions & 31 deletions integrations/slack/src/actions/queryAskAI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@ import {
IntegrationInstallation,
} from '@gitbook/api';

import {
SlackInstallationConfiguration,
SlackRuntimeEnvironment,
SlackRuntimeContext,
} from '../configuration';
import { SlackInstallationConfiguration, SlackRuntimeContext } from '../configuration';
import { slackAPI } from '../slack';
import { QueryDisplayBlock, ShareTools, decodeSlackEscapeChars, Spacer, SourcesBlock } from '../ui';
import {
Expand All @@ -22,36 +18,16 @@ import {
stripMarkdown,
} from '../utils';
import { Logger } from '@gitbook/runtime';
import { IntegrationTaskAskAI } from '../types';
import { AskAIActionParams, IntegrationTaskAskAI } from './types';

const logger = Logger('slack:queryAskAI');
const logger = Logger('slack:actions:askAI');

export type RelatedSource = {
id: string;
sourceUrl: string;
page: { path?: string; title: string };
};

export interface IQueryAskAI {
channelId: string;
channelName?: string;
responseUrl?: string;
teamId: string;
text: string;
context: SlackRuntimeContext;

/* postEphemeral vs postMessage */
messageType: 'ephemeral' | 'permanent';

/* needed for postEphemeral */
userId?: string;

/* Get AskAI reply in thread */
threadId?: string;

authorization?: string;
}

// Recursively extracts all pages from a collection of RevisionPages
function extractAllPages(rootPages: Array<RevisionPage>) {
const result: Array<RevisionPage> = [];
Expand Down Expand Up @@ -154,13 +130,13 @@ async function getRelatedSources(params: {
/*
* 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 queryAskAI(params: IQueryAskAI) {
export async function queryAskAI(params: AskAIActionParams) {
const {
channelId,
teamId,
threadId,
userId,
text,
queryText: text,
messageType,
context,
authorization,
Expand Down Expand Up @@ -217,7 +193,7 @@ export async function queryAskAI(params: IQueryAskAI) {
* Queues an integration task to process the AskAI query asynchronously.
*/
async function queueQueryAskAI(
params: IQueryAskAI & {
params: AskAIActionParams & {
query: string;
installation: IntegrationInstallation;
accessToken: string | undefined;
Expand Down Expand Up @@ -252,7 +228,7 @@ export async function handleAskAITask(task: IntegrationTaskAskAI, context: Slack
channelName,
channelId,
userId,
text,
queryText: text,
messageType,
responseUrl,
threadId,
Expand Down
78 changes: 78 additions & 0 deletions integrations/slack/src/actions/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { SlackRuntimeContext } from '../configuration';

interface ActionBaseParams {
channelId: string;
channelName?: string;
responseUrl?: string;
teamId: string;

context: SlackRuntimeContext;

/* needed for postEphemeral */
userId?: string;

/* Get reply in thread */
threadId?: string;
}

interface IngestSlackConversationWithConversation extends ActionBaseParams {
/**
* Used when the ingestion originates from a Slack conversation shortcut.
* The target conversation in this case is both the conversation to ingest
* and the one where notifications are sent.
* Identified by the `channelId` and `messageTs` values.
*/
conversationToIngest: {
channelId: string;
messageTs: string;
};
/**
* Not present when the ingestion is triggered directly from the conversation shortcut context.
*/
text?: never;
}

interface IngestSlackConversationWithText extends ActionBaseParams {
/**
* Used when the ingestion originates from outside the conversation to ingest,
* for example from a slash command that includes a permalink in the command text.
* The `text` field contains the permalink identifying the target conversation.
*/
text: string;
/**
* Not present when the ingestion is triggered using a text or link reference.
*/
conversationToIngest?: never;
}

export type IngestSlackConversationActionParams =
| IngestSlackConversationWithConversation
| IngestSlackConversationWithText;

export interface AskAIActionParams extends ActionBaseParams {
queryText: string;

/* postEphemeral vs postMessage */
messageType: 'ephemeral' | 'permanent';

authorization?: string;
}

export type IntegrationTaskType = 'ask:ai';

export type BaseIntegrationTask<Type extends IntegrationTaskType, Payload extends object> = {
type: Type;
payload: Payload;
};

export type IntegrationTaskAskAI = BaseIntegrationTask<
'ask:ai',
{
query: string;
organizationId: string;
installationId: string;
accessToken: string | undefined;
} & Omit<AskAIActionParams, 'context'>
>;

export type IntegrationTask = IntegrationTaskAskAI;
Loading