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:
Jon
2026-08-05 09:15:48 +01:00
committed by GitHub
parent 1546c95485
commit 204f346720
11 changed files with 815 additions and 64 deletions
@@ -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": []
}