mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Slack Node): Use Real-time Search API for message search (#35291)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,15 @@ export const userScopes = [
|
||||
'users:read',
|
||||
// Needed so /users.info returns the responder's email for the HITL capture-responder option.
|
||||
'users:read.email',
|
||||
// Real-time Search API (assistant.search.context) scopes, used by message:search
|
||||
// from node version 2.7. Message search only, so no files/users scopes.
|
||||
'search:read.public',
|
||||
'search:read.private',
|
||||
'search:read.im',
|
||||
'search:read.mpim',
|
||||
// NOTE: Kept so a credential that is re-authorized after 2.7 lands
|
||||
// can still run message:search on node versions <= 2.6
|
||||
// which call the deprecated search.messages.
|
||||
'search:read',
|
||||
];
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export class Slack extends VersionedNodeType {
|
||||
group: ['output'],
|
||||
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
|
||||
description: 'Consume Slack API',
|
||||
defaultVersion: 2.6,
|
||||
defaultVersion: 2.7,
|
||||
};
|
||||
|
||||
const nodeVersions: IVersionedNodeType['nodeVersions'] = {
|
||||
@@ -25,6 +25,7 @@ export class Slack extends VersionedNodeType {
|
||||
2.4: new SlackV2(baseDescription),
|
||||
2.5: new SlackV2(baseDescription),
|
||||
2.6: new SlackV2(baseDescription),
|
||||
2.7: new SlackV2(baseDescription),
|
||||
};
|
||||
|
||||
super(nodeVersions, baseDescription);
|
||||
|
||||
@@ -62,6 +62,74 @@ export function toMultiOptionsCsv(value: unknown): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns an `ok: false` Slack payload into a user-facing error. Exported so callers
|
||||
* that opt out of `slackApiRequest`'s error handling (to treat one error code as a
|
||||
* non-failure) can still map every other code the same way.
|
||||
*/
|
||||
export function throwOnSlackApiError(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
|
||||
// tslint:disable-next-line:no-any
|
||||
responseData: any,
|
||||
): never {
|
||||
if (responseData.error === 'paid_teams_only') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Your current Slack plan does not include the resource '${
|
||||
this.getNodeParameter('resource', 0) as string
|
||||
}'`,
|
||||
{
|
||||
description:
|
||||
'Hint: Upgrade to a Slack plan that includes the functionality you want to use.',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
} else if (responseData.error === 'ratelimited' || responseData.error === 'rate_limited') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Slack error response: ' + JSON.stringify(responseData.error),
|
||||
{
|
||||
description:
|
||||
'Wait before running this again, or request less data at a time. Limits differ per operation - see the Slack Documentation - https://docs.slack.dev/apis/web-api/rate-limits',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
} else if (responseData.error === 'missing_scope') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Your Slack credential is missing required Oauth Scopes',
|
||||
{
|
||||
description: `Add the following scope(s) to your Slack App: ${responseData.needed}`,
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
} else if (
|
||||
responseData.error === 'not_allowed_token_type' ||
|
||||
responseData.error === 'invalid_action_token'
|
||||
) {
|
||||
throw new NodeOperationError(this.getNode(), 'This Slack operation requires a user token', {
|
||||
description:
|
||||
'Bot tokens are not accepted here. Use OAuth2 authentication, or an Access Token credential holding a user token (starts with "xoxp-").',
|
||||
level: 'warning',
|
||||
});
|
||||
} else if (responseData.error === 'not_admin') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Need higher Role Level for this Operation (e.g. Owner or Admin Rights)',
|
||||
{
|
||||
description:
|
||||
'Hint: Check the Role of your Slack App Integration. For more information see the Slack Documentation - https://slack.com/help/articles/360018112273-Types-of-roles-in-Slack',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Slack error response: ' + JSON.stringify(responseData.error),
|
||||
);
|
||||
}
|
||||
|
||||
// Display label for a Slack user in pickers. Real names are friendlier but aren't
|
||||
// unique in Slack, so the handle is appended to keep same-named users distinguishable.
|
||||
// `real_name` is optional, so bots and unconfigured accounts show the handle alone.
|
||||
@@ -118,43 +186,7 @@ export async function slackApiRequest(
|
||||
|
||||
// don't try to handle errors if simple responses are disabled
|
||||
if (responseData.ok === false && options.simple !== false) {
|
||||
if (responseData.error === 'paid_teams_only') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`Your current Slack plan does not include the resource '${
|
||||
this.getNodeParameter('resource', 0) as string
|
||||
}'`,
|
||||
{
|
||||
description:
|
||||
'Hint: Upgrade to a Slack plan that includes the functionality you want to use.',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
} else if (responseData.error === 'missing_scope') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Your Slack credential is missing required Oauth Scopes',
|
||||
{
|
||||
description: `Add the following scope(s) to your Slack App: ${responseData.needed}`,
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
} else if (responseData.error === 'not_admin') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Need higher Role Level for this Operation (e.g. Owner or Admin Rights)',
|
||||
{
|
||||
description:
|
||||
'Hint: Check the Role of your Slack App Integration. For more information see the Slack Documentation - https://slack.com/help/articles/360018112273-Types-of-roles-in-Slack',
|
||||
level: 'warning',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Slack error response: ' + JSON.stringify(responseData.error),
|
||||
);
|
||||
throwOnSlackApiError.call(this, responseData);
|
||||
}
|
||||
|
||||
if (responseData.ts !== undefined) {
|
||||
@@ -308,6 +340,58 @@ export async function slackApiRequestAllItems(
|
||||
return returnData;
|
||||
}
|
||||
|
||||
/** Slack caps `limit` on assistant.search.context at 20 results per request. */
|
||||
const SEARCH_CONTEXT_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Cursor-paginates the Real-time Search API up to `maxResults`. It needs its own loop
|
||||
* because it takes arguments in the request body and returns `results.messages` with a
|
||||
* cursor, none of which the query-string based helpers above can express.
|
||||
*/
|
||||
export async function searchContextItems(
|
||||
this: IExecuteFunctions,
|
||||
body: IDataObject,
|
||||
maxResults: number,
|
||||
): Promise<IDataObject[]> {
|
||||
const returnData: IDataObject[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const responseData = await slackApiRequest.call(
|
||||
this,
|
||||
'POST',
|
||||
'/assistant.search.context',
|
||||
{
|
||||
...body,
|
||||
limit: Math.min(SEARCH_CONTEXT_PAGE_SIZE, maxResults - returnData.length),
|
||||
...(cursor ? { cursor } : {}),
|
||||
},
|
||||
{},
|
||||
undefined,
|
||||
// Errors are handled here so the pagination cap can end the loop instead of failing
|
||||
{ simple: false },
|
||||
);
|
||||
|
||||
// `simple: false` also suppresses HTTP-status errors, so anything that is not an
|
||||
// explicit success has to be raised here rather than parsed as results.
|
||||
if (responseData.ok !== true) {
|
||||
// Slack caps how deep a search can be paged. Hitting the cap means there is
|
||||
// nothing further to fetch, so keep what we have instead of failing the node.
|
||||
if (responseData.error === 'page_limit_exceeded') break;
|
||||
throwOnSlackApiError.call(this, responseData);
|
||||
}
|
||||
|
||||
const messages = (get(responseData, 'results.messages') as IDataObject[]) ?? [];
|
||||
returnData.push(...messages);
|
||||
cursor = get(responseData, 'response_metadata.next_cursor') as string | undefined;
|
||||
|
||||
// An empty page with a cursor would otherwise spin forever
|
||||
if (messages.length === 0) break;
|
||||
} while (cursor && returnData.length < maxResults);
|
||||
|
||||
return returnData;
|
||||
}
|
||||
|
||||
export function getMessageContent(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
i: number,
|
||||
|
||||
@@ -1334,6 +1334,8 @@ export const messageFields: INodeProperties[] = [
|
||||
],
|
||||
default: 'desc',
|
||||
},
|
||||
// Dropped from 2.7: the Real-time Search API caps how deep a search can be paged and
|
||||
// warns that paginating past ~10 calls rate limits the whole workspace.
|
||||
{
|
||||
displayName: 'Return All',
|
||||
name: 'returnAll',
|
||||
@@ -1342,6 +1344,7 @@ export const messageFields: INodeProperties[] = [
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['search'],
|
||||
'@version': [{ _cnd: { lte: 2.6 } }],
|
||||
},
|
||||
},
|
||||
default: false,
|
||||
@@ -1356,6 +1359,7 @@ export const messageFields: INodeProperties[] = [
|
||||
resource: ['message'],
|
||||
operation: ['search'],
|
||||
returnAll: [false],
|
||||
'@version': [{ _cnd: { lte: 2.6 } }],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
@@ -1365,6 +1369,38 @@ export const messageFields: INodeProperties[] = [
|
||||
default: 25,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName: 'Limit',
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['search'],
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
typeOptions: {
|
||||
minValue: 1,
|
||||
maxValue: 50,
|
||||
},
|
||||
default: 25,
|
||||
description: 'Max number of results to return',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'Searches are rate limited by Slack. <a target="_blank" href="https://docs.slack.dev/reference/methods/assistant.search.context#rate-limiting">Check the Slack docs for the current limits</a>.',
|
||||
name: 'searchRateLimitNotice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['search'],
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
@@ -1376,6 +1412,109 @@ export const messageFields: INodeProperties[] = [
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
displayName: 'After',
|
||||
name: 'after',
|
||||
type: 'dateTime',
|
||||
default: '',
|
||||
description: 'Only return messages sent after this date',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Before',
|
||||
name: 'before',
|
||||
type: 'dateTime',
|
||||
default: '',
|
||||
description: 'Only return messages sent before this date',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Channel Types',
|
||||
name: 'channelTypes',
|
||||
type: 'multiOptions',
|
||||
default: ['public_channel', 'private_channel', 'mpim', 'im'],
|
||||
description: 'Which kinds of conversation to search in',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
options: [
|
||||
{
|
||||
name: 'Public Channel',
|
||||
value: 'public_channel',
|
||||
},
|
||||
{
|
||||
name: 'Private Channel',
|
||||
value: 'private_channel',
|
||||
},
|
||||
{
|
||||
name: 'Group DM',
|
||||
value: 'mpim',
|
||||
},
|
||||
{
|
||||
name: 'DM',
|
||||
value: 'im',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'Include Archived Channels',
|
||||
name: 'includeArchivedChannels',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include archived channels in the results',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Bots',
|
||||
name: 'includeBots',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to include messages sent by bots',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Include Message Blocks',
|
||||
name: 'includeMessageBlocks',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Whether to return the rich text blocks of each message alongside its text',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Keyword Search Only',
|
||||
name: 'keywordSearchOnly',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description:
|
||||
'Whether to match keywords only. By default Slack also returns semantically similar messages.',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 2.7 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Search in Channel',
|
||||
name: 'searchChannel',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
processThreadOptions,
|
||||
slackApiRequestAllItemsWithRateLimit,
|
||||
toMultiOptionsCsv,
|
||||
searchContextItems,
|
||||
} from './GenericFunctions';
|
||||
import {
|
||||
advancedInteractivityNotice,
|
||||
@@ -65,7 +66,7 @@ export class SlackV2 implements INodeType {
|
||||
constructor(baseDescription: INodeTypeBaseDescription) {
|
||||
this.description = {
|
||||
...baseDescription,
|
||||
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6],
|
||||
version: [2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7],
|
||||
defaults: {
|
||||
name: 'Slack',
|
||||
},
|
||||
@@ -1088,35 +1089,87 @@ export class SlackV2 implements INodeType {
|
||||
responseData = await slackApiRequest.call(this, 'GET', '/chat.getPermalink', {}, qs);
|
||||
}
|
||||
//https://api.slack.com/methods/search.messages
|
||||
//https://docs.slack.dev/reference/methods/assistant.search.context
|
||||
if (operation === 'search') {
|
||||
let query = this.getNodeParameter('query', i) as string;
|
||||
const sort = this.getNodeParameter('sort', i) as string;
|
||||
const returnAll = this.getNodeParameter('returnAll', i);
|
||||
const returnAll = this.getNodeParameter('returnAll', i, false);
|
||||
const options = this.getNodeParameter('options', i);
|
||||
if (options.searchChannel) {
|
||||
const channel = options.searchChannel as IDataObject[];
|
||||
for (const channelItem of channel) {
|
||||
query += ` in:${channelItem}`;
|
||||
const sortBy = sort === 'relevance' ? 'score' : 'timestamp';
|
||||
const sortDir = sort === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if (nodeVersion >= 2.7) {
|
||||
// assistant.search.context has no argument for this - its `modifiers` only
|
||||
// applies to term clauses - so the filter goes in the query text, which
|
||||
// Slack parses channel filters out of
|
||||
for (const channel of toMultiOptionsCsv(options.searchChannel)
|
||||
.split(',')
|
||||
.filter(Boolean)) {
|
||||
query += ` in:${channel}`;
|
||||
}
|
||||
}
|
||||
qs = {
|
||||
query,
|
||||
sort: sort === 'relevance' ? 'score' : 'timestamp',
|
||||
sort_dir: sort === 'asc' ? 'asc' : 'desc',
|
||||
};
|
||||
if (returnAll) {
|
||||
responseData = await slackApiRequestAllItems.call(
|
||||
// channel_types/content_types are array args: the JSON body needs real
|
||||
// arrays, not the comma-separated form used for query-string params
|
||||
const body: IDataObject = {
|
||||
query,
|
||||
content_types: ['messages'],
|
||||
sort: sortBy,
|
||||
sort_dir: sortDir,
|
||||
};
|
||||
const channelTypes = toMultiOptionsCsv(options.channelTypes);
|
||||
if (channelTypes) {
|
||||
body.channel_types = channelTypes.split(',');
|
||||
}
|
||||
const timezone = this.getTimezone();
|
||||
if (options.after) {
|
||||
body.after = moment.tz(options.after as string, timezone).unix();
|
||||
}
|
||||
if (options.before) {
|
||||
body.before = moment.tz(options.before as string, timezone).unix();
|
||||
}
|
||||
if (options.keywordSearchOnly) {
|
||||
body.disable_semantic_search = true;
|
||||
}
|
||||
if (options.includeArchivedChannels) {
|
||||
body.include_archived_channels = true;
|
||||
}
|
||||
if (options.includeBots) {
|
||||
body.include_bots = true;
|
||||
}
|
||||
if (options.includeMessageBlocks) {
|
||||
body.include_message_blocks = true;
|
||||
}
|
||||
|
||||
responseData = await searchContextItems.call(
|
||||
this,
|
||||
'messages',
|
||||
'GET',
|
||||
'/search.messages',
|
||||
{},
|
||||
qs,
|
||||
body,
|
||||
this.getNodeParameter('limit', i) as number,
|
||||
);
|
||||
} else {
|
||||
qs.count = this.getNodeParameter('limit', i);
|
||||
responseData = await slackApiRequest.call(this, 'POST', '/search.messages', {}, qs);
|
||||
responseData = responseData.messages.matches;
|
||||
if (options.searchChannel) {
|
||||
const channel = options.searchChannel as IDataObject[];
|
||||
for (const channelItem of channel) {
|
||||
query += ` in:${channelItem}`;
|
||||
}
|
||||
}
|
||||
qs = {
|
||||
query,
|
||||
sort: sortBy,
|
||||
sort_dir: sortDir,
|
||||
};
|
||||
if (returnAll) {
|
||||
responseData = await slackApiRequestAllItems.call(
|
||||
this,
|
||||
'messages',
|
||||
'GET',
|
||||
'/search.messages',
|
||||
{},
|
||||
qs,
|
||||
);
|
||||
} else {
|
||||
qs.count = this.getNodeParameter('limit', i);
|
||||
responseData = await slackApiRequest.call(this, 'POST', '/search.messages', {}, qs);
|
||||
responseData = responseData.messages.matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"author_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"author_user_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"blocks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"block_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"elements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"elements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"channel_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"channel_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_author_bot": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"message_ts": {
|
||||
"type": "string"
|
||||
},
|
||||
"permalink": {
|
||||
"type": "string"
|
||||
},
|
||||
"team_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"version": 1
|
||||
}
|
||||
@@ -5,13 +5,13 @@ describe('Slack', () => {
|
||||
|
||||
it('should expose every released version and default to the latest', () => {
|
||||
expect(Object.keys(node.nodeVersions).map(Number)).toEqual([
|
||||
1, 2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6,
|
||||
1, 2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7,
|
||||
]);
|
||||
expect(node.description.defaultVersion).toBe(2.6);
|
||||
expect(node.description.defaultVersion).toBe(2.7);
|
||||
});
|
||||
|
||||
it('should list the same V2 versions in the version description', () => {
|
||||
const v2 = node.nodeVersions[2.6];
|
||||
expect(v2.description.version).toEqual([2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6]);
|
||||
expect(v2.description.version).toEqual([2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
slackApiRequest,
|
||||
slackApiRequestAllItems,
|
||||
slackApiRequestAllItemsWithRateLimit,
|
||||
searchContextItems,
|
||||
formatUserLabel,
|
||||
processThreadOptions,
|
||||
getMessageContent,
|
||||
@@ -88,6 +89,36 @@ describe('Slack V2 > GenericFunctions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['ratelimited', 'rate_limited'])('should handle %s error', async (error) => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error });
|
||||
|
||||
await expect(
|
||||
slackApiRequest.call(mockExecuteFunctions, 'POST', '/assistant.search.context'),
|
||||
).rejects.toMatchObject({
|
||||
message: `Slack error response: "${error}"`,
|
||||
description: expect.stringContaining('web-api/rate-limits'),
|
||||
level: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['not_allowed_token_type', 'invalid_action_token'])(
|
||||
'should handle %s error',
|
||||
async (error) => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error });
|
||||
|
||||
await expect(
|
||||
slackApiRequest.call(mockExecuteFunctions, 'POST', '/assistant.search.context'),
|
||||
).rejects.toMatchObject({
|
||||
message: 'This Slack operation requires a user token',
|
||||
level: 'warning',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle missing_scope error without needed scopes', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
@@ -986,6 +1017,87 @@ describe('Slack V2 > GenericFunctions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchContextItems', () => {
|
||||
const page = (messages: object[], nextCursor: string) => ({
|
||||
ok: true,
|
||||
results: { messages },
|
||||
response_metadata: { next_cursor: nextCursor },
|
||||
});
|
||||
|
||||
it('should follow the cursor until it is empty', async () => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page([{ content: 'one' }], 'cursor-2'))
|
||||
.mockResolvedValueOnce(page([{ content: 'two' }], ''));
|
||||
|
||||
const result = await searchContextItems.call(mockExecuteFunctions, { query: 'test' }, 50);
|
||||
|
||||
expect(result).toEqual([{ content: 'one' }, { content: 'two' }]);
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(mockExecuteFunctions.helpers.requestWithAuthentication as Mock).mock.calls[0][1].body,
|
||||
).toEqual({ query: 'test', limit: 20 });
|
||||
expect(
|
||||
(mockExecuteFunctions.helpers.requestWithAuthentication as Mock).mock.calls[1][1].body,
|
||||
).toEqual({ query: 'test', limit: 20, cursor: 'cursor-2' });
|
||||
});
|
||||
|
||||
it('should cap the page size at 20 and stop once maxResults is reached', async () => {
|
||||
const first = Array.from({ length: 20 }, (_, index) => ({ content: `msg-${index}` }));
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page(first, 'cursor-2'))
|
||||
.mockResolvedValueOnce(page([{ content: 'msg-20' }, { content: 'msg-21' }], 'cursor-3'));
|
||||
|
||||
const result = await searchContextItems.call(mockExecuteFunctions, { query: 'test' }, 22);
|
||||
|
||||
expect(result).toHaveLength(22);
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
(mockExecuteFunctions.helpers.requestWithAuthentication as Mock).mock.calls[0][1].body,
|
||||
).toEqual({ query: 'test', limit: 20 });
|
||||
expect(
|
||||
(mockExecuteFunctions.helpers.requestWithAuthentication as Mock).mock.calls[1][1].body,
|
||||
).toEqual({ query: 'test', limit: 2, cursor: 'cursor-2' });
|
||||
});
|
||||
|
||||
it('should keep the collected results when the pagination cap is hit', async () => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page([{ content: 'one' }], 'cursor-2'))
|
||||
.mockResolvedValueOnce({ ok: false, error: 'page_limit_exceeded' });
|
||||
|
||||
const result = await searchContextItems.call(mockExecuteFunctions, { query: 'test' }, 50);
|
||||
|
||||
expect(result).toEqual([{ content: 'one' }]);
|
||||
});
|
||||
|
||||
it('should still throw on any other error mid-pagination', async () => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(page([{ content: 'one' }], 'cursor-2'))
|
||||
.mockResolvedValueOnce({ ok: false, error: 'missing_scope', needed: 'search:read.im' });
|
||||
|
||||
await expect(
|
||||
searchContextItems.call(mockExecuteFunctions, { query: 'test' }, 50),
|
||||
).rejects.toMatchObject({
|
||||
message: 'Your Slack credential is missing required Oauth Scopes',
|
||||
description: 'Add the following scope(s) to your Slack App: search:read.im',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop on an empty page even when a cursor is returned', async () => {
|
||||
mockExecuteFunctions.helpers.requestWithAuthentication = vi
|
||||
.fn()
|
||||
.mockResolvedValue(page([], 'cursor-2'));
|
||||
|
||||
const result = await searchContextItems.call(mockExecuteFunctions, { query: 'test' }, 50);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockExecuteFunctions.helpers.requestWithAuthentication).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processThreadOptions', () => {
|
||||
it('should return empty object when threadOptions is undefined', () => {
|
||||
const result = processThreadOptions(undefined);
|
||||
|
||||
@@ -3,10 +3,12 @@ import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INode,
|
||||
INodeParameters,
|
||||
INodeProperties,
|
||||
INodeExecutionData,
|
||||
INodeParameterResourceLocator,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeOperationError } from 'n8n-workflow';
|
||||
import { displayParameter, NodeOperationError } from 'n8n-workflow';
|
||||
|
||||
import { SlackV2 } from '../../V2/SlackV2.node';
|
||||
import * as GenericFunctions from '../../V2/GenericFunctions';
|
||||
@@ -2515,4 +2517,42 @@ describe('SlackV2', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('Message Operations - Search parameter visibility', () => {
|
||||
const searchProps = (typeVersion: number) => {
|
||||
const description = new SlackV2({
|
||||
displayName: 'Slack',
|
||||
name: 'slack',
|
||||
group: ['output'],
|
||||
description: 'Consume Slack API',
|
||||
}).description;
|
||||
|
||||
const base = { resource: 'message', operation: 'search' };
|
||||
const isShown = (property: INodeProperties, nodeValues: INodeParameters) =>
|
||||
displayParameter(nodeValues, property, { typeVersion }, description);
|
||||
|
||||
// Mirror the editor: a parameter that is not displayed never lands in the node's
|
||||
// values, so returnAll must be absent (not false) on versions that hide it.
|
||||
const showsReturnAll = description.properties.some(
|
||||
(property) => property.name === 'returnAll' && isShown(property, base),
|
||||
);
|
||||
const nodeValues = showsReturnAll ? { ...base, returnAll: false } : base;
|
||||
|
||||
return description.properties
|
||||
.filter((property) => isShown(property, nodeValues))
|
||||
.map((property) => property.name);
|
||||
};
|
||||
|
||||
it('should offer Return All only up to 2.6', () => {
|
||||
expect(searchProps(2.6)).toContain('returnAll');
|
||||
expect(searchProps(2.7)).not.toContain('returnAll');
|
||||
});
|
||||
|
||||
// Limit is declared twice so that dropping Return All in 2.7 does not hide it: the
|
||||
// <= 2.6 variant is only shown when returnAll is false, which no longer resolves.
|
||||
it('should offer exactly one Limit on both sides of the version split', () => {
|
||||
for (const typeVersion of [2.6, 2.7]) {
|
||||
expect(searchProps(typeVersion).filter((name) => name === 'limit')).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { NodeTestHarness } from '@nodes-testing/node-test-harness';
|
||||
import nock from 'nock';
|
||||
|
||||
const MESSAGES = [
|
||||
{
|
||||
author_name: 'michael.k',
|
||||
author_user_id: 'U0362BXQYJW',
|
||||
team_id: 'T0364MSFHV2',
|
||||
channel_id: 'C08514ZPKB8',
|
||||
channel_name: 'test-002',
|
||||
message_ts: '1734322597.935429',
|
||||
content: 'test message',
|
||||
is_author_bot: false,
|
||||
permalink: 'https://myspace-qhg7381.slack.com/archives/C08514ZPKB8/p1734322597935429',
|
||||
blocks: [
|
||||
{
|
||||
type: 'rich_text',
|
||||
block_id: '+bc',
|
||||
elements: [
|
||||
{
|
||||
type: 'rich_text_section',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'test message',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author_name: 'michael.k',
|
||||
author_user_id: 'U0362BXQYJW',
|
||||
team_id: 'T0364MSFHV2',
|
||||
channel_id: 'C08514ZPKB8',
|
||||
channel_name: 'test-002',
|
||||
message_ts: '1734322341.161179',
|
||||
content: 'another test message',
|
||||
is_author_bot: false,
|
||||
permalink: 'https://myspace-qhg7381.slack.com/archives/C08514ZPKB8/p1734322341161179',
|
||||
blocks: [
|
||||
{
|
||||
type: 'rich_text',
|
||||
block_id: 'FGAKN',
|
||||
elements: [
|
||||
{
|
||||
type: 'rich_text_section',
|
||||
elements: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'another test message',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const API_RESPONSE = {
|
||||
ok: true,
|
||||
results: {
|
||||
messages: MESSAGES,
|
||||
},
|
||||
response_metadata: {
|
||||
next_cursor: '',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Test SlackV2 v2.7, message => search', () => {
|
||||
nock('https://slack.com')
|
||||
.post('/api/assistant.search.context', {
|
||||
query: 'test in:test-002 in:test-003',
|
||||
content_types: ['messages'],
|
||||
sort: 'timestamp',
|
||||
sort_dir: 'desc',
|
||||
channel_types: ['public_channel', 'private_channel'],
|
||||
disable_semantic_search: true,
|
||||
include_archived_channels: true,
|
||||
include_bots: true,
|
||||
include_message_blocks: true,
|
||||
limit: 2,
|
||||
})
|
||||
.reply(200, API_RESPONSE);
|
||||
|
||||
new NodeTestHarness().setupTests({
|
||||
workflowFiles: ['searchV27.workflow.json'],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"name": "slack tests",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "e679c883-1839-47dc-9511-8f7dc370e6b0",
|
||||
"name": "When clicking ‘Execute workflow’",
|
||||
"type": "n8n-nodes-base.manualTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [820, 360]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"operation": "search",
|
||||
"query": "test",
|
||||
"limit": 2,
|
||||
"options": {
|
||||
"searchChannel": ["test-002", "test-003"],
|
||||
"channelTypes": ["public_channel", "private_channel"],
|
||||
"keywordSearchOnly": true,
|
||||
"includeArchivedChannels": true,
|
||||
"includeBots": true,
|
||||
"includeMessageBlocks": true
|
||||
}
|
||||
},
|
||||
"id": "2e1937a6-4c8f-4cd1-ae42-11b2bd12cc4c",
|
||||
"name": "Slack",
|
||||
"type": "n8n-nodes-base.slack",
|
||||
"typeVersion": 2.7,
|
||||
"position": [1040, 360],
|
||||
"credentials": {
|
||||
"slackApi": {
|
||||
"id": "Bg0bWXf8apAimCqJ",
|
||||
"name": "Slack account 2"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "06652908-6b8e-443a-9508-ab229b011b73",
|
||||
"name": "No Operation, do nothing",
|
||||
"type": "n8n-nodes-base.noOp",
|
||||
"typeVersion": 1,
|
||||
"position": [1260, 360]
|
||||
}
|
||||
],
|
||||
"pinData": {
|
||||
"No Operation, do nothing": [
|
||||
{
|
||||
"json": {
|
||||
"author_name": "michael.k",
|
||||
"author_user_id": "U0362BXQYJW",
|
||||
"team_id": "T0364MSFHV2",
|
||||
"channel_id": "C08514ZPKB8",
|
||||
"channel_name": "test-002",
|
||||
"message_ts": "1734322597.935429",
|
||||
"content": "test message",
|
||||
"is_author_bot": false,
|
||||
"permalink": "https://myspace-qhg7381.slack.com/archives/C08514ZPKB8/p1734322597935429",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "rich_text",
|
||||
"block_id": "+bc",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "test message"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"json": {
|
||||
"author_name": "michael.k",
|
||||
"author_user_id": "U0362BXQYJW",
|
||||
"team_id": "T0364MSFHV2",
|
||||
"channel_id": "C08514ZPKB8",
|
||||
"channel_name": "test-002",
|
||||
"message_ts": "1734322341.161179",
|
||||
"content": "another test message",
|
||||
"is_author_bot": false,
|
||||
"permalink": "https://myspace-qhg7381.slack.com/archives/C08514ZPKB8/p1734322341161179",
|
||||
"blocks": [
|
||||
{
|
||||
"type": "rich_text",
|
||||
"block_id": "FGAKN",
|
||||
"elements": [
|
||||
{
|
||||
"type": "rich_text_section",
|
||||
"elements": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "another test message"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"connections": {
|
||||
"When clicking ‘Execute workflow’": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Slack",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Slack": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "No Operation, do nothing",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "0ea58133-0522-40ae-ab51-ec3c1eced057",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "be251a83c052a9862eeac953816fbb1464f89dfbf79d7ac490a8e336a8cc8bfd"
|
||||
},
|
||||
"id": "qJdEfiBgYLdfYOTs",
|
||||
"tags": []
|
||||
}
|
||||
Reference in New Issue
Block a user